Skip to content

Latest commit

 

History

History
202 lines (144 loc) · 8.61 KB

File metadata and controls

202 lines (144 loc) · 8.61 KB

Rust Interop (No __rust__ in apps)

This target supports an escape hatch (__rust__) for emitting raw Rust, but the proposed v1.0 policy is:

  • Application code should stay “pure Haxe” (no raw __rust__ calls).
  • Rust interop belongs in framework code (std/, runtime/) behind typed APIs.

This doc describes the recommended, documented pattern for binding to Rust.

For APIs whose Rust shape needs explicit lifetimes, HRTB, const generics, or macro-heavy setup, use the Extern and lifetime-island cookbook as the practical template.

@:rustExtraSrc remains in the generated application crate. It cannot contain unsafe code when the application forbids unsafe. The planned typed support-crate facility reserves @:rustSupportCrate for a separate, content-bound crate. The compiler rejects that metadata until the complete facility lands.

Compiler-generated native wrappers are not a shipped user feature yet. The M94 native wrapper facility spike reserves @:rustNativeWrapper for future simple value-wrapper generation, and the compiler rejects that metadata today. Use @:rustExtraSrc plus the native facade manifest for current handwritten helper islands.

Preferred pattern: extern + @:native(...) + extra Rust modules

1) Write the Rust module (hand-written)

Put a .rs file in a directory and include it via -D rust_extra_src=...:

  • Haxe: -D rust_extra_src=native (directory relative to the haxe working directory)
  • Rust file: native/my_module.rs

The compiler copies it into the generated crate and emits mod my_module; automatically.

2) Bind from Haxe with extern + @:native(...)

@:native("crate::my_module")
extern class MyModule {
  @:native("some_fn")
  public static function someFn(x:Int): Int;
}

Notes:

  • @:native("crate::my_module") maps the extern class to a Rust module path.
  • @:native("some_fn") maps the Haxe field to the Rust function name.
  • Keep the extern surface tiny and then wrap it with more idiomatic Haxe APIs if needed.

3) Test it with snapshots or cargo tests

  • For small APIs, add a snapshot under test/snapshot/* that compiles + builds the generated crate.
  • For richer behavior, add native/*.rs tests and run cargo test in CI (see examples/tui_todo).

Cargo dependencies from Haxe

Prefer declarative dependencies:

  • Put @:rustCargo({ name: "crate_name", version: "x.y" }) metadata on the extern type that needs it.

This keeps Cargo wiring centralized and avoids ad-hoc Cargo.toml edits in app repos.

@:rustCargo forms

Two forms are supported:

  • Raw TOML line:
    • @:rustCargo("ratatui = \"0.26\"")
  • Structured object (recommended; deterministic + mergeable):
    • @:rustCargo({ name: "serde", version: "1", features: ["derive"] })

@:rustCargo object fields

Supported fields:

  • name (required): crate name
  • version: Cargo version requirement (e.g. "1", "0.26", "^1.2")
  • features: array of feature strings
  • defaultFeatures: boolean (false to emit default-features = false)
  • optional: boolean
  • path: local path dependency
  • git: git URL dependency
  • branch / tag / rev: optional git selectors
  • package: override the package name (Cargo’s package = "..." field)

If multiple modules declare @:rustCargo for the same crate:

  • features are unioned + de-duped (stable order)
  • most other fields must match (conflicts produce a compile-time error)

Extra Rust trait impls (@:rustImpl)

Sometimes you want to implement a Rust trait for a Haxe-emitted type without dropping down to raw __rust__ in app code (for example Display, or a small marker trait).

Use @:rustImpl(...) metadata on the Haxe class/enum:

@:rustImpl("std::marker::Unpin")
@:rustImpl("std::fmt::Display",
  "fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n" +
  "  write!(f, \"Foo({})\", self.x)\n" +
  "}")
class Foo {
  public var x:Int;
  public function new(x:Int) this.x = x;
}

Supported forms:

  • @:rustImpl("path::Trait") emits an empty impl block: impl path::Trait for Type { }
  • @:rustImpl("path::Trait", "fn ...") emits the provided string as the inner body of the impl block
  • @:rustImpl({ trait: "path::Trait", body: "fn ...", forType: "SomeType" }) (advanced)
    • forType overrides the Rust type name used on the right-hand side of for ...

The strings that identify the trait and optional target type are parsed at the metadata boundary. After that, the compiler keeps the impl header as typed Rust structure, so analysis can see its paths and generic arguments. When a body string is supplied, only that inner body remains raw metadata; the surrounding impl Trait for Type { ... } syntax is still compiler-owned and structurally printed. The positional form is intentionally strict: use exactly one string for a marker impl or exactly two strings for an impl with a body. Extra arguments and non-string bodies are reported at compile time instead of being ignored or silently reinterpreted as marker impls.

Limitations:

  • Rust orphan rules still apply. In practice, this is primarily useful for implementing external traits for local types (types emitted by this compiler). If both the trait and the target type are external, Rust will reject the impl.
  • Trait paths use the compiler's closed structural metadata grammar. It accepts stable path forms used by current fixtures, including trailing commas in generic/function-trait argument lists and signed decimal const arguments such as Marker<-1,>. Braced const expressions such as Marker<{ N + 1 }> are not modeled yet; use a named const path or a typed extern/native island instead of hiding that expression in compiler-owned syntax.
  • Impl body strings are a narrow metadata escape hatch, not the long-term app authoring model for common Rust trait patterns. See Metal trait, impl, and bound model for the current contract and planned typed surfaces. The compiler-wide raw-authority inventory proves that the body is the only metadata-owned raw producer and that no compiler-owned raw lowering remains.

Escape hatch: __rust__ injection (framework-only)

If a binding is awkward to express as an extern (generics/closures, tricky lifetimes, etc.), you can use __rust__ inside framework code as a last resort.

Two ways exist:

  • untyped __rust__("...{0}...", arg0) — works in normal (non-macro) modules
  • reflaxe.rust.macros.RustInjection.__rust__("...{0}...", arg0, arg1, ...) — macro shim that provides a typed callable surface (and helps in files that also define macros)

Important:

  • Examples/snapshots are guarded by -D reflaxe_rust_strict_examples and will fail if __rust__ leaks into user code via inlining.
  • Both injection branches are listed explicitly in the generated raw-authority inventory; adding another producer requires a reviewed inventory update.

Scoped raw authority (@:rustAllowRaw)

If a narrow low-level abstraction module genuinely needs raw __rust__, you can mark the owning type/module with @:rustAllowRaw.

Use this sparingly:

  • good fit: a small user-owned bridge module that cannot be expressed cleanly as an extern yet
  • bad fit: application business logic, examples, or general "Rust everywhere" authoring

Important limits:

  • @:rustAllowRaw only relaxes strict boundary enforcement (reflaxe_rust_strict and reflaxe_rust_strict_examples) for the tagged module.
  • It does not bypass metal or @:rustMetal raw-fallback restrictions.
  • If the same module is compiled as metal or tagged @:rustMetal, raw fallback still errors.

Practical rule:

  1. Prefer typed externs and metadata first.
  2. If that still cannot express the boundary, use @:rustAllowRaw in one small wrapper module.
  3. Keep the rest of the codebase on typed Haxe APIs.

Dynamic boundaries (stdlib compatibility)

Some Haxe std APIs are intentionally dynamic across all targets. For those, this backend keeps Dynamic only at the API boundary, then converts immediately to typed data.

Current important example:

  • haxe.Json.parse(text):Dynamic
    • Required by upstream std API contract.
    • Rust runtime parses with typed serde_json::Value.
    • The Dynamic value is only the outward compatibility layer.
    • haxe.Json.parseValue(text):haxe.json.Value is the typed path and should be preferred in compiler/runtime/example code.

Rule of thumb:

  • If upstream API requires Dynamic, keep it only at that seam and document the conversion path.
  • Otherwise, prefer typed externs/typedefs/classes end-to-end.