Skip to content

Commit 5a40471

Browse files
fix: narrow nullable scalar switch once
Haxe rewrites a nullable scalar switch into a null test and a local alias in the present branch. The old Rust lowering tested the same Option again and kept a dead, catchable Null Access throw. Recognize that exact typed shape for Copy scalar values and emit one Option match. Portable and metal snapshots now cover Int and Float values, including both present and null cases. Verified with the focused snapshot and Clippy gate, adjacent nullable and switch snapshots, repository hooks, root Cargo formatting, and root Clippy with warnings denied. Other nullable shapes keep the existing checked lowering.
1 parent b4975cb commit 5a40471

68 files changed

Lines changed: 18092 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.beads/issues.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,4 +820,5 @@
820820
{"_type":"issue","id":"haxe.rust-t8e","title":"Emit profile contract report artifacts for portable|metal builds","description":"Add opt-in profile contract artifacts (json+md) that record effective profile policy switches and contract diagnostics for deterministic CI review.","notes":"Source spike: haxe.rust-8z8. Keep deterministic ordering and typed serializers.","status":"closed","priority":4,"issue_type":"task","owner":"boss@fullofcaffeine.com","created_at":"2026-02-24T17:48:34Z","created_by":"Marcelo Serpa","updated_at":"2026-02-24T18:09:26Z","closed_at":"2026-02-24T12:09:26Z","close_reason":"Closed","labels":["architecture","family","profiles"],"dependencies":[{"issue_id":"haxe.rust-t8e","depends_on_id":"haxe.rust-8z8","type":"discovered-from","created_at":"2026-02-24T11:48:34Z","created_by":"Marcelo Serpa","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
821821
{"_type":"issue","id":"haxe.rust-8z8","title":"Spike: align reflaxe.rust to family portable|metal contract after haxe.go canonicalization","description":"## Why\nCross-compiler investigation indicates the family should standardize on `portable|metal` as profile contracts, with profiles as presets over orthogonal axes (strictness, runtime policy, interop policy), while preserving target-specific internals.\n\n## Inputs\n- Canonical direction from `../haxe.go/docs/portable-canonical-contract.md`\n- Go phase roadmap: `../haxe.go/docs/phase2-roadmap.md`\n- Go selective runtime plan: `../haxe.go/docs/hxrt-selective-runtime.md`\n- Rust implementation references:\n - `src/reflaxe/rust/ProfileResolver.hx`\n - `src/reflaxe/rust/analyze/HxrtFeatureAnalyzer.hx`\n - `src/reflaxe/rust/emit/ProjectEmitter.hx`\n - `src/reflaxe/rust/passes/NoHxrtPass.hx`\n - `docs/profiles.md`, `docs/metal-profile.md`\n\n## Spike Scope\n1. Validate where Rust already matches the family model and where terminology/contracts drift.\n2. Define a family-aligned artifact contract for Rust:\n - profile contract report\n - runtime plan report (features selected + reasons)\n3. Propose minimal changes needed for explicit family parity without regressing Rust-specific strengths (feature slicing, no-hxrt, fallback).\n\n## Deliverables\n- `docs/spikes/family-portable-metal-alignment.md` (or equivalent) with:\n - current state\n - gap matrix\n - migration tasks\n - risk notes\n- Beads follow-up tasks created from that matrix (all low priority unless blocking).\n\n## Future Follow-up Trigger\nAfter `haxe.go` lands selective runtime slicing + metal fallback + runtime-plan reporting, run a new GPT-5.2 Pro cross-repo spike comparing latest Go/Rust/Elixir/hxhx and refresh family guidance.\n","status":"closed","priority":4,"issue_type":"task","owner":"boss@fullofcaffeine.com","created_at":"2026-02-24T17:12:05Z","created_by":"Marcelo Serpa","updated_at":"2026-07-19T21:15:36Z","closed_at":"2026-02-24T11:49:16Z","close_reason":"Closed","labels":["architecture","family","metal","portable","spike"],"dependency_count":0,"dependent_count":0,"comment_count":0}
822822
{"_type":"issue","id":"haxe.rust-oo3.11","title":"Milestone 10 — Performance + idiomatic mode optimizations","description":"Deliverables:\n- Optional `-D rust_native` / `-D rust_idiomatic` mode\n- Arrays → `Vec\u003cT\u003e` when semantics allow\n- Reduce clones in hot paths\n- Better loop lowering to iterators\n- Optional `rustfmt` integration (`-D rustfmt`)\n- Compilation output determinism guarantees\n","acceptance_criteria":"- Benchmarks show measurable improvements on representative workloads\n- Output stays readable and stable across runs\n","status":"closed","priority":4,"issue_type":"epic","owner":"boss@fullofcaffeine.com","created_at":"2026-01-23T18:08:53Z","created_by":"Marcelo Serpa","updated_at":"2026-01-24T02:17:36Z","closed_at":"2026-01-23T20:17:36Z","close_reason":"Closed","labels":["idiomatic","m10","milestone","perf"],"dependencies":[{"issue_id":"haxe.rust-oo3.11","depends_on_id":"haxe.rust-oo3","type":"parent-child","created_at":"2026-01-23T12:08:53Z","created_by":"Marcelo Serpa","metadata":"{}"},{"issue_id":"haxe.rust-oo3.11","depends_on_id":"haxe.rust-oo3.10","type":"blocks","created_at":"2026-01-23T12:08:54Z","created_by":"Marcelo Serpa","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
823+
{"_type":"issue","id":"haxe_rust-4tc8","title":"Remove dead null throw after nullable scalar switch narrowing","description":"A Haxe switch over Null\u003cInt\u003e can lower to an outer null guard and a non-null branch that still treats the bound value as Option\u003ci32\u003e. The generated Rust rematches that Option and includes a dead hxrt Null Access throw. codex-hxrust exposed the problem while it narrowed sys.io.Process.exitCode(true) before storing an Int in a typed report. Keep the fix generic and compiler-owned. Do not add Codex or process-specific recognition.","acceptance_criteria":"Add a product-neutral Haxe fixture for a Null\u003cInt\u003e switch with null and bound-value arms. Before the fix, the fixture must show the redundant Option rematch and dead Null Access throw. After the fix, generated Rust must bind or unwrap the non-null value without that dead throw. The fixture must build and run in the applicable profiles. Adjacent nullable-scalar and switch gates, repository hooks, and the unchanged codex-hxrust consumer gate must pass. Inspect the generated Rust and record deferred nullable shapes, if any.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"repository-maintainer","owner":"boss@fullofcaffeine.com","created_at":"2026-09-01T01:09:28Z","created_by":"repository-maintainer","updated_at":"2026-09-01T01:09:28Z","labels":["codegen","consumer-pressure","nullability","output-quality","thinking:high"],"dependencies":[{"issue_id":"haxe_rust-4tc8","depends_on_id":"haxe.rust-luv2","type":"discovered-from","created_at":"2026-09-01T01:09:28Z","created_by":"repository-maintainer","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
823824
{"_type":"issue","id":"haxe_rust-igkk","title":"Reduce inherited-shim duplication in generated Rust","description":"Subclass lowering copies inherited method bodies and emits broad forwarding trait implementations for each child. A small compatibility subclass of a 658-line typed session generated roughly 2,000 Rust lines, while the base session generated roughly 2,300 lines after concrete and interface adapters. Preserve Haxe inheritance and interface dispatch while reducing duplicate generated methods and review noise generically.","design":"Start from a small framework-neutral base, child, and interface fixture. Measure generated functions and lines before changing lowering. Reuse base implementations or emit only required child adapters without changing virtual dispatch, private-helper access, self-to-interface coercion, or public compatibility.","acceptance_criteria":"A generic inheritance fixture preserves interpreter and generated-Rust behavior while materially reducing duplicate child method bodies and forwarding adapters. Existing inheritance, generic-interface specialization, and inherited-interface-self fixtures remain green. Generated Rust is warning-clean and rustfmt-clean, and a consumer output review confirms that a test-only compatibility subclass no longer duplicates the complete base implementation.","status":"open","priority":1,"issue_type":"task","owner":"boss@fullofcaffeine.com","created_at":"2026-08-30T19:31:35Z","created_by":"repository-maintainer","updated_at":"2026-08-30T19:31:35Z","labels":["compiler","inheritance","output-quality","thinking:high"],"dependencies":[{"issue_id":"haxe_rust-igkk","depends_on_id":"haxe_rust-uwml","type":"discovered-from","created_at":"2026-08-30T19:31:35Z","created_by":"repository-maintainer","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}

docs/semantic-confidence-summary.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"portableCandidateMissingFromTier2": 0,
1919
"portableSemanticDiffCases": 37,
2020
"laneSemanticDiffCases": 2,
21-
"snapshotCases": 141,
21+
"snapshotCases": 142,
2222
"bucketsByClass": {
2323
"compile_inventory": 2,
2424
"targeted_semantic_parity": 8,

docs/semantic-confidence-summary.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ This summary rolls up the current evidence buckets without pretending that Tier2
2626
- Portable candidate missing from Tier2: `0`
2727
- Portable semantic-diff cases: `37`
2828
- Lane semantic-diff cases: `2`
29-
- Snapshot cases: `141`
29+
- Snapshot cases: `142`
3030
- Compile/inventory buckets: `2`
3131
- Targeted semantic/runtime buckets: `8`
3232
- Snapshot/smoke-only buckets: `7`

scripts/lint/dynamic_allowlist.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ src/reflaxe/rust/RustCompiler.hx:2803
1515
src/reflaxe/rust/RustCompiler.hx:2806
1616
src/reflaxe/rust/RustCompiler.hx:2814
1717
src/reflaxe/rust/RustCompiler.hx:10839
18-
src/reflaxe/rust/RustCompiler.hx:13636
19-
src/reflaxe/rust/RustCompiler.hx:13655
20-
src/reflaxe/rust/RustCompiler.hx:19875
18+
src/reflaxe/rust/RustCompiler.hx:13641
19+
src/reflaxe/rust/RustCompiler.hx:13660
20+
src/reflaxe/rust/RustCompiler.hx:19972
2121
src/reflaxe/rust/RustSourceMap.hx:419
2222
src/reflaxe/rust/analyze/RepresentationAnalysisSnapshot.hx:77
2323
src/reflaxe/rust/analyze/RepresentationAnalysisSnapshot.hx:81

src/reflaxe/rust/RustCompiler.hx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11128,6 +11128,11 @@ class RustCompiler extends GenericCompiler<RustFile, RustFile, RustExpr, RustFil
1112811128
compileUnop(op, postFix, expr, e);
1112911129

1113011130
case TIf(cond, eThen, eElse):
11131+
if (eElse != null) {
11132+
var narrowed = compileNullableScalarSwitchNarrowing(cond, eThen, eElse, e.t);
11133+
if (narrowed != null)
11134+
return narrowed;
11135+
}
1113111136
var condExpr = coerceExprToExpected(compileExpr(cond), cond, Context.getType("Bool"));
1113211137
if (eElse == null) {
1113311138
// `if (...) expr;` in Haxe is statement-shaped; ensure the Rust `if` branches yield `()`.
@@ -14184,6 +14189,98 @@ class RustCompiler extends GenericCompiler<RustFile, RustFile, RustExpr, RustFil
1418414189
}
1418514190
}
1418614191

14192+
/**
14193+
Lowers Haxe's proven non-null scalar switch arm without a second null check.
14194+
14195+
Why
14196+
- Haxe rewrites `switch (value:Null<Int>)` into a null test and a non-null branch.
14197+
- The non-null branch contains `var bound = value; bound`, but both local types remain
14198+
`Null<Int>` in the typed tree.
14199+
- Normal nullable coercion must keep a catchable `Null Access` branch. In this exact branch,
14200+
the preceding condition already proves that the value is present.
14201+
14202+
What
14203+
- Recognizes only the two-expression alias block produced for a nullable scalar switch.
14204+
- Emits one native `Option` match that binds the present payload directly.
14205+
14206+
How
14207+
- Requires the null comparison, alias initializer, and alias tail to name the same Haxe local.
14208+
- Requires a Copy inner type and the exact non-null Rust result type, then compiles the source once.
14209+
- Returns `null` for every other conditional so ordinary Haxe null behavior stays unchanged.
14210+
**/
14211+
function compileNullableScalarSwitchNarrowing(cond:TypedExpr, thenExpr:TypedExpr, elseExpr:TypedExpr,
14212+
expected:Type):Null<RustExpr> {
14213+
var condition = unwrapMetaParen(cond);
14214+
var comparedLocal:Null<TVar> = null;
14215+
var nullWhenTrue = false;
14216+
switch (condition.expr) {
14217+
case TBinop(op, left, right) if (op == OpEq || op == OpNotEq):
14218+
var leftValue = unwrapMetaParen(left);
14219+
var rightValue = unwrapMetaParen(right);
14220+
if (isNullConstExpr(leftValue)) {
14221+
switch (rightValue.expr) {
14222+
case TLocal(variable): comparedLocal = variable;
14223+
case _:
14224+
}
14225+
} else if (isNullConstExpr(rightValue)) {
14226+
switch (leftValue.expr) {
14227+
case TLocal(variable): comparedLocal = variable;
14228+
case _:
14229+
}
14230+
}
14231+
nullWhenTrue = op == OpEq;
14232+
case _:
14233+
}
14234+
if (comparedLocal == null)
14235+
return null;
14236+
14237+
var presentBranch = nullWhenTrue ? elseExpr : thenExpr;
14238+
var nullBranch = nullWhenTrue ? thenExpr : elseExpr;
14239+
var present = unwrapMetaParen(presentBranch);
14240+
var expressions = switch (present.expr) {
14241+
case TBlock(items) if (items.length == 2): items;
14242+
case _: null;
14243+
};
14244+
if (expressions == null)
14245+
return null;
14246+
14247+
var alias:Null<TVar> = null;
14248+
var initializer:Null<TypedExpr> = null;
14249+
switch (unwrapMetaParen(expressions[0]).expr) {
14250+
case TVar(variable, value) if (value != null):
14251+
alias = variable;
14252+
initializer = unwrapMetaParen(value);
14253+
case _:
14254+
}
14255+
if (alias == null || initializer == null)
14256+
return null;
14257+
14258+
var sourceMatches = switch (initializer.expr) {
14259+
case TLocal(variable): variable.id == comparedLocal.id;
14260+
case _: false;
14261+
};
14262+
var tailMatches = switch (unwrapMetaParen(expressions[1]).expr) {
14263+
case TLocal(variable): variable.id == alias.id;
14264+
case _: false;
14265+
};
14266+
if (!sourceMatches || !tailMatches)
14267+
return null;
14268+
14269+
var inner = nullOptionInnerType(alias.t, present.pos);
14270+
if (inner == null || !isCopyType(inner))
14271+
return null;
14272+
if (!rustTypesEqual(toRustType(inner, present.pos), toRustType(expected, present.pos)))
14273+
return null;
14274+
14275+
// The emitted match replaces the typed null comparison, so consume that skipped local read.
14276+
consumeLocalRead(comparedLocal);
14277+
var nullRust = coerceExprToExpected(compileBranchExpr(nullBranch), nullBranch, expected);
14278+
return EMatch(compileExpr(initializer), [
14279+
{pat: PPath(RustPath.single("None")), expr: nullRust},
14280+
{pat: PTupleStruct(RustPath.single("Some"), [PBind("__hx_value")]), expr: rustSingleExpr("__hx_value")}
14281+
]);
14282+
}
14283+
1418714284
/**
1418814285
Checks whether one function type parameter disappears from every lowered Rust argument type.
1418914286

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class Main {
2+
static function narrow(value:Null<Int>):Int {
3+
return switch (value) {
4+
case null: -1;
5+
case concrete: concrete;
6+
};
7+
}
8+
9+
static function narrowFloat(value:Null<Float>):Float {
10+
return switch (value) {
11+
case null: -1.5;
12+
case concrete: concrete;
13+
};
14+
}
15+
16+
static function main():Void {
17+
if (narrow(7) != 7) throw "non-null narrowing failed";
18+
if (narrow(null) != -1) throw "null narrowing failed";
19+
if (narrowFloat(2.5) != 2.5) throw "non-null Float narrowing failed";
20+
if (narrowFloat(null) != -1.5) throw "null Float narrowing failed";
21+
}
22+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-cp .
2+
-lib reflaxe.rust
3+
-D reflaxe_rust_strict_examples
4+
-D rust_output=out
5+
-D rust_cargo_subcommand=run
6+
-D rust_cargo_quiet
7+
-D reflaxe.dont_output_metadata_id
8+
-main Main
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-cp .
2+
-lib reflaxe.rust
3+
-D reflaxe_rust_profile=metal
4+
-D rust_output=out_metal
5+
-D rust_cargo_subcommand=run
6+
-D rust_cargo_quiet
7+
-D reflaxe.dont_output_metadata_id
8+
-main Main
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/target
2+
**/*.rs.bk
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[package]
2+
name = "hx_app"
3+
version = "0.0.1"
4+
edition = "2021"
5+
rust-version = "1.96.0"
6+
resolver = "3"
7+
8+
[dependencies]
9+
hxrt = { path = "./hxrt", default-features = false, features = ["core"] }

0 commit comments

Comments
 (0)