diff --git a/.gitignore b/.gitignore index e0ba434915f..c5c51198e37 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ target !src/target generated-docs +plan.md zig-out zig-pkg @@ -47,6 +48,7 @@ zig-pkg *.def *.tmp *.wasm +!test/cli/rocci_bird_postcheck_panic/platform/targets/wasm32/host.wasm *.exe *.pdb @@ -156,5 +158,5 @@ test/glue/*/roc_platform_abi.zig test/echo/builtin_doc_*.roc tmp/ -# Scratch planning docs (CI forbids tracking these) +# Local implementation planning notes / scratch planning docs (CI forbids tracking these) plan.md diff --git a/build.zig b/build.zig index 65c17c034c1..6b8f870e987 100644 --- a/build.zig +++ b/build.zig @@ -2773,6 +2773,7 @@ pub fn build(b: *std.Build) void { .imports = &.{ .{ .name = "test_harness", .module = createTestHarnessModule(b, roc_modules) }, .{ .name = "collections", .module = roc_modules.collections }, + .{ .name = "backend", .module = roc_modules.backend }, }, }), }); @@ -3592,6 +3593,72 @@ pub fn build(b: *std.Build) void { build_wasm_str_concat_join_app.step.dependOn(build_test_hosts_step); build_test_wasm_static_lib_runner_step.dependOn(&build_wasm_str_concat_join_app.step); + // End-to-end cart gate for the minted-iterator `for`-loop drive. The + // size build covers the LLVM cart path, and the dev build covers wasm + // composite loop-state rebinding for recursive generated iterators. + const build_wasm_iter_for_app = b.addRunArtifact(roc_exe); + build_wasm_iter_for_app.addArgs(&.{ + "build", + "test/wasm/iter_for_static_lib_app.roc", + "--opt=size", + "--target=wasm32", + "--output=test/wasm/iter_for_static_lib_app.wasm", + }); + build_wasm_iter_for_app.step.dependOn(build_test_hosts_step); + build_test_wasm_static_lib_runner_step.dependOn(&build_wasm_iter_for_app.step); + + const build_wasm_iter_for_dev_app = b.addRunArtifact(roc_exe); + build_wasm_iter_for_dev_app.addArgs(&.{ + "build", + "test/wasm/iter_for_static_lib_app.roc", + "--opt=dev", + "--target=wasm32", + "--output=test/wasm/iter_for_static_lib_app_dev.wasm", + }); + build_wasm_iter_for_dev_app.step.dependOn(build_test_hosts_step); + build_test_wasm_static_lib_runner_step.dependOn(&build_wasm_iter_for_dev_app.step); + + // Dev-mode recursive iterator construction must converge at the + // explicit forced-dynamic representation tier. + const build_wasm_iter_recursive_concat_app = b.addRunArtifact(roc_exe); + build_wasm_iter_recursive_concat_app.addArgs(&.{ + "build", + "test/wasm/iter_recursive_concat_static_lib_app.roc", + "--opt=dev", + "--target=wasm32", + "--output=test/wasm/iter_recursive_concat_static_lib_app.wasm", + }); + build_wasm_iter_recursive_concat_app.step.dependOn(build_test_hosts_step); + build_test_wasm_static_lib_runner_step.dependOn(&build_wasm_iter_recursive_concat_app.step); + + // Static-data hoisting gate: a constant list literal consumed via + // `.iter()` must materialize as static data and allocate nothing, so the + // whole minted chain (base list included) is zero-alloc on the cart path. + const build_wasm_iter_list_hoist_app = b.addRunArtifact(roc_exe); + build_wasm_iter_list_hoist_app.addArgs(&.{ + "build", + "test/wasm/iter_list_hoist_static_lib_app.roc", + "--opt=size", + "--target=wasm32", + "--output=test/wasm/iter_list_hoist_static_lib_app.wasm", + }); + build_wasm_iter_list_hoist_app.step.dependOn(build_test_hosts_step); + build_test_wasm_static_lib_runner_step.dependOn(&build_wasm_iter_list_hoist_app.step); + + // Noiter twin: the same sums over plain list literals. The runner prints + // each cart's byte size, so the iter build minus this baseline is the + // minted-adapter premium tracked in CI (the fusion pass's target). + const build_wasm_iter_noiter_app = b.addRunArtifact(roc_exe); + build_wasm_iter_noiter_app.addArgs(&.{ + "build", + "test/wasm/iter_for_noiter_static_lib_app.roc", + "--opt=size", + "--target=wasm32", + "--output=test/wasm/iter_for_noiter_static_lib_app.wasm", + }); + build_wasm_iter_noiter_app.step.dependOn(build_test_hosts_step); + build_test_wasm_static_lib_runner_step.dependOn(&build_wasm_iter_noiter_app.step); + const build_wasm_rc_cleanup_app = b.addRunArtifact(roc_exe); build_wasm_rc_cleanup_app.addArgs(&.{ "build", @@ -3614,6 +3681,17 @@ pub fn build(b: *std.Build) void { build_wasm_rc_cleanup_model_list_app.step.dependOn(build_test_hosts_step); build_test_wasm_static_lib_runner_step.dependOn(&build_wasm_rc_cleanup_model_list_app.step); + const build_wasm_boxed_model_update_app = b.addRunArtifact(roc_exe); + build_wasm_boxed_model_update_app.addArgs(&.{ + "build", + "test/wasm/boxed_model_update_static_lib_app.roc", + "--opt=dev", + "--target=wasm32", + "--output=test/wasm/boxed_model_update_static_lib_app.wasm", + }); + build_wasm_boxed_model_update_app.step.dependOn(build_test_hosts_step); + build_test_wasm_static_lib_runner_step.dependOn(&build_wasm_boxed_model_update_app.step); + const wasm_test_exe = b.addExecutable(.{ .name = "wasm_static_lib_test", .root_module = b.createModule(.{ @@ -3689,6 +3767,81 @@ pub fn build(b: *std.Build) void { run_wasm_str_concat_join_test.step.dependOn(build_test_wasm_static_lib_runner_step); run_test_wasm_static_lib_step.dependOn(&run_wasm_str_concat_join_test.step); + // Boot-and-play the minted-iterator `for`-loop cart; "ok" means every + // inlined `for` over append/map/concat/chained minted chains ran to + // completion with correct sums (i.e. the drive advanced its inner + // iterators and terminated). `--assert-alloc-balanced` also catches a + // per-step allocate/free leak in the drive. + const run_wasm_iter_for_test = b.addRunArtifact(wasm_test_exe); + run_wasm_iter_for_test.addArgs(&.{ + "--wasm-path", + "test/wasm/iter_for_static_lib_app.wasm", + "--expected", + "ok", + "--assert-alloc-balanced", + // Absolute-size ceiling: the un-fused minted cart is ~48 KB + // (premium ~18 KB over the noiter twin). This catches a gross + // size blowup; the premium itself is read from the two printed + // sizes and is the fusion pass's target, not a hard gate pre-fusion. + "--max-bytes", + "65536", + }); + run_wasm_iter_for_test.step.dependOn(build_test_wasm_static_lib_runner_step); + run_test_wasm_static_lib_step.dependOn(&run_wasm_iter_for_test.step); + + const run_wasm_iter_recursive_concat_test = b.addRunArtifact(wasm_test_exe); + run_wasm_iter_recursive_concat_test.addArgs(&.{ + "--wasm-path", + "test/wasm/iter_recursive_concat_static_lib_app.wasm", + "--expected", + "ok", + "--assert-alloc-balanced", + // The fixed cart is ~542 KB. The prior unbounded generated + // callable expansion exceeded 815 KB before failing to lower. + "--max-bytes", + "600000", + }); + run_wasm_iter_recursive_concat_test.step.dependOn(build_test_wasm_static_lib_runner_step); + run_test_wasm_static_lib_step.dependOn(&run_wasm_iter_recursive_concat_test.step); + + // Static-data hoisting: the constant list literal is materialized as + // static data, so the whole chain allocates nothing. `--max-allocs 0` + // is a strictly stronger assertion than `--assert-alloc-balanced`. + const run_wasm_iter_list_hoist_test = b.addRunArtifact(wasm_test_exe); + run_wasm_iter_list_hoist_test.addArgs(&.{ + "--wasm-path", + "test/wasm/iter_list_hoist_static_lib_app.wasm", + "--expected", + "ok", + "--max-allocs", + "0", + }); + run_wasm_iter_list_hoist_test.step.dependOn(build_test_wasm_static_lib_runner_step); + run_test_wasm_static_lib_step.dependOn(&run_wasm_iter_list_hoist_test.step); + + const run_wasm_iter_for_dev_test = b.addRunArtifact(wasm_test_exe); + run_wasm_iter_for_dev_test.addArgs(&.{ + "--wasm-path", + "test/wasm/iter_for_static_lib_app_dev.wasm", + "--expected", + "ok", + "--assert-alloc-balanced", + }); + run_wasm_iter_for_dev_test.step.dependOn(build_test_wasm_static_lib_runner_step); + run_test_wasm_static_lib_step.dependOn(&run_wasm_iter_for_dev_test.step); + + // Noiter twin — asserts correctness and prints its size so CI logs + // carry both numbers for premium tracking. + const run_wasm_iter_noiter_test = b.addRunArtifact(wasm_test_exe); + run_wasm_iter_noiter_test.addArgs(&.{ + "--wasm-path", + "test/wasm/iter_for_noiter_static_lib_app.wasm", + "--expected", + "ok", + }); + run_wasm_iter_noiter_test.step.dependOn(build_test_wasm_static_lib_runner_step); + run_test_wasm_static_lib_step.dependOn(&run_wasm_iter_noiter_test.step); + const run_wasm_rc_cleanup_test = b.addRunArtifact(wasm_test_exe); run_wasm_rc_cleanup_test.addArgs(&.{ "--wasm-path", @@ -3714,6 +3867,16 @@ pub fn build(b: *std.Build) void { }); run_wasm_rc_cleanup_model_list_test.step.dependOn(build_test_wasm_static_lib_runner_step); run_test_wasm_static_lib_step.dependOn(&run_wasm_rc_cleanup_model_list_test.step); + + const run_wasm_boxed_model_update_test = b.addRunArtifact(wasm_test_exe); + run_wasm_boxed_model_update_test.addArgs(&.{ + "--wasm-path", + "test/wasm/boxed_model_update_static_lib_app.wasm", + "--expected", + "ok", + }); + run_wasm_boxed_model_update_test.step.dependOn(build_test_wasm_static_lib_runner_step); + run_test_wasm_static_lib_step.dependOn(&run_wasm_boxed_model_update_test.step); } run_wasm_test.step.dependOn(build_test_wasm_static_lib_runner_step); run_test_wasm_static_lib_step.dependOn(&run_wasm_test.step); @@ -5664,14 +5827,20 @@ fn addMainExe( } } + const use_bundled_deps = !use_system_llvm and user_llvm_path == null; + const config = b.addOptions(); config.addOption(bool, "llvm", true); + config.addOption(bool, "binaryen", use_bundled_deps); exe.root_module.addOptions("config", config); exe.root_module.addAnonymousImport("legal_details", .{ .root_source_file = b.path("legal_details") }); const llvm_paths_exe = llvmPaths(b, target, use_system_llvm, user_llvm_path) orelse return null; exe.root_module.addLibraryPath(.{ .cwd_relative = llvm_paths_exe.lib }); exe.root_module.addIncludePath(.{ .cwd_relative = llvm_paths_exe.include }); + if (use_bundled_deps) { + addStaticBinaryenOptionsToModule(exe.root_module); + } try addStaticLlvmOptionsToModule(exe.root_module); add_tracy(b, roc_modules.build_options, exe, target, true, tracy); @@ -5970,6 +6139,18 @@ fn addStaticLlvmOptionsToModule(mod: *std.Build.Module) !void { } } +fn addStaticBinaryenOptionsToModule(mod: *std.Build.Module) void { + const link_static = std.Build.Module.LinkSystemLibraryOptions{ + .preferred_link_mode = .static, + .search_strategy = .mode_first, + }; + mod.addCSourceFile(.{ + .file = .{ .cwd_relative = "src/build/zig_binaryen.cpp" }, + .flags = &exe_cflags, + }); + mod.linkSystemLibrary("binaryen", link_static); +} + const cpp_sources = [_][]const u8{ "src/build/zig_llvm.cpp", }; diff --git a/build.zig.zon b/build.zig.zon index bdd1e664a35..566093c9bd0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,43 +9,43 @@ .lazy = true, }, .roc_deps_aarch64_macos_none = .{ - .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0/aarch64-macos-none.tar.xz", - .hash = "N-V-__8AAEvEEA9B1q8qGkm3rJW_bkae4wn1SvyrfDa0w1lp", + .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0-binaryen/aarch64-macos-none.tar.xz", + .hash = "N-V-__8AAKS-VRH7JXsaDHpnFPSd-B5fSdtnDbh0XrfnncWc", .lazy = true, }, .roc_deps_aarch64_linux_musl = .{ - .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0/aarch64-linux-musl.tar.xz", - .hash = "N-V-__8AAHPjwBNV6lTtxPO6DYe4lg2Gx5Qakmeg1PO7N5k7", + .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0-binaryen/aarch64-linux-musl.tar.xz", + .hash = "N-V-__8AACK4KheKSiltX0PPURTNh0CvJhsopNXzcXpvq9pS", .lazy = true, }, .roc_deps_aarch64_windows_gnu = .{ - .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0/aarch64-windows-gnu.zip", - .hash = "N-V-__8AAI4OFxV11RN1xAhLXxLTyaxZh3Wbi4U1Dza1OESo", + .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0-binaryen/aarch64-windows-gnu.zip", + .hash = "N-V-__8AAPEjNhjVXuc6-b70fcyHFGS_ckUIx5_ICD3-3HZk", .lazy = true, }, .roc_deps_arm_linux_musleabihf = .{ - .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0/arm-linux-musleabihf.tar.xz", - .hash = "N-V-__8AAHU1FhRO-2yZIDQWw5rkVVuUYn5purRT9mAqykzw", + .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0-binaryen/arm-linux-musleabihf.tar.xz", + .hash = "N-V-__8AAKywPxfY01H2OMTuZllxIuavfyGGm3kpW7-1RmkP", .lazy = true, }, .roc_deps_x86_linux_musl = .{ - .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0/x86-linux-musl.tar.xz", - .hash = "N-V-__8AACNk7BFESU37UNPvVOXf2dGyPMjtDSsji-VYqvmz", + .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0-binaryen/x86-linux-musl.tar.xz", + .hash = "N-V-__8AAMg8ihSrg9Udc8ZJ1trVVUgglxJg8CbHoYG51dZq", .lazy = true, }, .roc_deps_x86_64_linux_musl = .{ - .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0/x86_64-linux-musl.tar.xz", - .hash = "N-V-__8AAJmm4xTmXSEZeLYLtOlZWKI_Kitjx2TOzm9vhCWM", + .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0-binaryen/x86_64-linux-musl.tar.xz", + .hash = "N-V-__8AAGJLMhhn8pu3uyxtKTIlha8CxCjE6TNpLYvvj-cz", .lazy = true, }, .roc_deps_x86_64_macos_none = .{ - .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0/x86_64-macos-none.tar.xz", - .hash = "N-V-__8AAJGgsQ-GR2yR2tLFM4OM0z7ClQ0Rx7SjviQbx8Ks", + .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0-binaryen/x86_64-macos-none.tar.xz", + .hash = "N-V-__8AAJrG0hG7ZWMT8yxRBa17ivn77bWqDpseO904PYT7", .lazy = true, }, .roc_deps_x86_64_windows_gnu = .{ - .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0/x86_64-windows-gnu.zip", - .hash = "N-V-__8AAJAOJxiRcaWQZpXytjyF8_Q7Mj9g7xXQWtegD-6C", + .url = "https://github.com/roc-lang/roc-bootstrap/releases/download/zig-0.16.0-binaryen/x86_64-windows-gnu.zip", + .hash = "N-V-__8AADfFZhvsrNly3AbA2PmVP9piXKNto_CmeqLuNNsd", .lazy = true, }, .bytebox = .{ diff --git a/design.md b/design.md index 1d57098f12c..1a02aa01b19 100644 --- a/design.md +++ b/design.md @@ -27,18 +27,20 @@ Everything in between those boundaries is a Cor-style typed IR pipeline: checked modules -> Monotype IR -> Monotype Lifted IR + -> optional SpecConstr -> Lambda Solved IR - -> Lambda Mono decisions + -> solved inline plan + -> direct Solved-to-LIR decisions -> LIR -> ARC insertion -> backend, interpreter, or LirImage ``` -There is no separate MIR layer. There is no separate stored layout IR between -Lambda Mono and LIR. Layout selection is owned by the direct Lambda Mono to LIR -builder. In optimized builds, Lambda Mono is represented by explicit callable -and procedure decision tables consumed by direct LIR lowering, not by a second -stored expression, pattern, and statement tree. +There is no separate MIR layer and no separate stored layout IR. Layout and +logical Lambda Mono callable/procedure decisions are owned by +`SolvedLirLower` while it directly consumes Lambda Solved syntax. Release builds +do not store a second expression, pattern, and statement tree. Debug builds may +materialize Lambda Mono only to verify the direct decisions. ## Core Principles @@ -94,6 +96,352 @@ selected by LIR ARC insertion. Consumers may lazily cache code or interpreter execution plans for that helper, but they must not select a different helper from local layout data. Reference-counting policy belongs to LIR ARC insertion. +Recursive walks over post-check types and values must be bounded. A structure +reachable after checking can be self-referential — a recursive nominal's +backing, or the fixpoint value of a recursively-constructed chain (an iterator +wrapped around itself a runtime number of times) — so "this walk terminates" +is an assumption, not a property, unless the walk either traverses a provably +acyclic structure or carries an explicit budget. When a budget is exhausted, +the walk must fail toward the conservative answer for its question — decline +the optimization, keep the value materialized, or select an explicitly defined +dynamic representation — never toward a hang, an unbounded specialization set, +or a wrong result. The +budget must be chosen so exhaustion errs in the safe direction for that +specific question: a substitution check answers "cannot substitute" (a missed +optimization), and a minted-chain depth walk reports the cap (the chain takes +the explicit `forced_dynamic` representation). Two standing instances: Monotype bounds +minted iterator chain depth at the single construction choke point +(`generatedIteratorType`), which is what guarantees specialization terminates +for recursively-constructed chains regardless of call structure; and +constructor specialization bounds its substitution-candidate value walk +(`valueCanSubstitute`), because a loop-carried value can reference itself and +a cyclic value is correctly non-substitutable anyway. + +## Checking Effects And Const Roots + +Checking owns Roc effect validation, compile-time evaluation eligibility, and +compile-time root selection. These are checked-stage responsibilities, not +post-check repairs. The checker must finish with explicit outputs for function +effect kinds, top-level effect errors, effectful `expect` errors, compile-time +diagnostics, and selected compile-time roots. Later stages consume those +outputs directly. + +The pre-check CIR producer outputs CIR and checked identity inputs; it does not +own compile-time root selection. Root selection happens during checking because +checking already walks every expression, resolves local identity, computes +types, validates function effects, and receives static-dispatch results. The +checker must not perform a later whole-module expression walk merely to decide +which expressions are roots, and later stages must not recreate those answers. + +The question "can this expression be evaluated at compile time?" depends only +on checked data dependency, checked control reachability, and effectfulness. It +does not depend on whether a call was direct or static-dispatch syntax, whether +an expression is a leaf, whether a value was written inline or named at the top +level, or whether the expression contains `crash`, `dbg`, or `expect`. Those +constructs are compile-time observable, and evaluating them at compile time is +required when the surrounding expression has no runtime data dependency, no +runtime control dependency, and no effectful call. + +Control reachability is checked data, not source-shape guessing. An expression +can be a standalone compile-time root only when the source meaning evaluates it +unconditionally whenever the root is needed. Branch bodies, match +guards, and match branch values are control-dependent on the enclosing +conditional or match. They may contribute summaries to an enclosing `if` or +`match` root, but they must not add independent selected roots while their +enclosing control decision can be made at runtime. Otherwise an untaken branch +containing `crash`, `dbg`, or `expect` would run during `roc check`, which +would change the program's observable behavior. If the whole enclosing control +expression is compile-time-known and effect-free, the enclosing expression may +be selected as the root and the evaluator follows the same branch choices as +the source program. + +Non-local control-transfer expressions such as `return` and `break` are not +standalone value roots and cannot cover child candidates by themselves. Their +payloads may still contribute to an enclosing eligible root or be selected as +ordinary child expressions when they are reached through checked control data. +Making the control-transfer expression itself a root would require an explicit +checked continuation representation; until that exists, selecting it as a +stored constant is a compiler bug, not an optimization choice. + +An effectful call is one of: + +- a direct call to a checked effectful function +- a call through a function-typed value whose checked function type is effectful +- a static-dispatch call whose selected implementation is checked effectful + +Creating a function value is not an effectful call, even when that function's +body is effectful. The effect propagates only when the function value is called. +Negative effect answers are not durable until the relevant slot has finalized; +static dispatch can still turn an apparently pure call site into an effectful +call before checked output is produced. + +### Effect Slots + +Roc effect propagation is a directed dataflow problem over function bodies and +call sites. It must not be represented by one early boolean that is finalized +before static dispatch has resolved. The checker maintains sparse effect slots +for the places where effectfulness is part of the checked result: + +- function and lambda bodies +- top-level value right-hand sides +- `expect` bodies +- compile-time root candidates whose effectfulness may depend on delayed + dispatch + +An effect slot becomes effectful when it contains a direct call to an effectful +function, when a delayed static-dispatch call watched by the slot resolves to an +effectful function, or when it calls another slot that is effectful. Ordinary +calls add directed dependencies from caller slot to callee slot. Static-dispatch +calls add watcher entries from the dispatch function variable to the active +slot. When static-dispatch resolution later proves the selected method is +effectful, the watcher marks or connects the owning slot before any checked +output is finalized. + +Effect dependencies are directed. A caller depending on a callee must not be +represented as equality. Strongly connected recursive groups may be condensed +for solving, but unrelated caller and callee slots must remain one-way +dependencies. + +Effects are not inferred from source spelling alone. A `!` name contributes to +identifier parsing and annotations, but the checked source of truth is the +resolved function type and dispatch result. A method call whose syntax appears +inside a pure-looking expression can still make that expression effectful after +dispatch resolution. Conversely, `crash`, `dbg`, and `expect` are not real +effectful calls. They must never be used as reasons to reject a compile-time +root. + +Effect finalization runs after ordinary type constraints, literal defaulting, +and static-dispatch constraints for the relevant boundary have settled. It +computes final slot effectfulness with directed graph propagation. After +finalization, the checker uses the slot results to select `fn_pure`, +`fn_effectful`, or the equivalent checked function kind, to report invalid pure +annotations, to report effectful top-level values, and to report effectful +`expect` bodies. Checked module output must not contain unresolved effect +kinds. + +The effect solver may cache positive effectfulness immediately. It must not +treat an unresolved negative answer as final while dispatch watchers or callee +slots can still change. Recursive groups are solved by directed propagation: +strongly connected groups can be condensed, marked effectful if any member is +effectful, and then propagated to callers. Ordinary caller-to-callee edges must +remain one-way. + +Every static-dispatch call that is not resolved when the expression frame +finishes is represented by explicit checked state. The active effect slot owns +the watcher for that dispatch function variable. If the same expression is also +a compile-time root candidate, the root candidate records that it is waiting on +the same slot. When the dispatch result arrives, effect finalization updates the +slot once, and both the function-effect answer and the root-selection answer +consume that finalized slot. Root selection must not infer delayed dispatch by +re-reading syntax or by searching for unresolved method names. + +### Root Selection During Checking + +Compile-time root selection uses the same checker traversal that already walks +checked CIR expressions. There is no separate root-selection walk over every +expression. While checking an expression, the checker returns a small +transient summary to its parent: + +```text +runtime dependency status +control reachability status +effect slot or delayed-effect status when needed +candidate stack interval owned by this expression frame +``` + +The summary is stack-local for ordinary nested expressions. The checker stores +only data needed after the current expression finishes: summaries for bindings +that later lookups may read, effect slots and dispatch watchers, tentative root +candidates, and final selected roots. It must not allocate a permanent +per-expression table merely to answer root eligibility. + +Runtime dependency is computed bottom-up from checked CIR identity. Lambda +arguments, match-bound values, loop-bound values, mutable variables, and +reassignments are runtime-dependent. Immutable local definitions store the +summary of their right-hand side; later local lookups consume that stored +summary. Top-level checked values and imported checked values are checked +binding identities at the use site. Looking up a module-level binding is +compile-time-known as a reference to that checked binding; the initializer's +own evaluation, diagnostics, reachability, and static storage are handled by +the module-level checked outputs, not by replaying the initializer summary into +each lookup expression. Parent expressions combine child summaries directly. + +The expression summary is not a second effect system. Effect slots remain the +owner of effectfulness. The expression summary only says whether the expression +is already known effect-free, already known effectful, or waiting on an effect +slot that can still be marked by delayed dispatch or callee propagation. + +An expression that already produced a checking problem is poisoned for +compile-time root selection. Poison is not runtime dependency and it is not an +effect. It only prevents an erroneous parent value from becoming a selected +root while preserving the original diagnostic ownership, so a bad child reports +once instead of being hidden by hoisting or reported again by a parent root. +Static-dispatch failures, type errors, and other checker-owned problems must +feed this poison result explicitly through the same expression summary path. +Poison is local to the expression or dependency region that owns the checking +problem. It propagates only through explicit checked dependencies, such as a +lookup of an erroneous local or top-level value. It must never become a module, +package, or program flag. A checked module or checked program may contain +user-facing diagnostics and still produce hoisted roots for every independent +expression whose own dependency region is resolved and otherwise eligible. This +is required for Roc's recover-and-continue behavior: `roc check`, tests, and +program execution must keep doing all valid work that does not depend on the +erroneous code path. +No downstream compile-time-evaluation step may use a CheckedModule's +nonempty diagnostic list as a reason to skip independent roots; it must consume +the explicit root list and the per-root poisoned/dependency state produced by +checking. +Compiler implementation gaps are not poison. Once checking has accepted an +eligible expression, failure to evaluate, store, restore, or emit it correctly +is a compiler bug with a regression test, not a reason to demote the expression +from compile-time evaluation. + +Root selection keeps maximal eligible expressions. Each expression frame +records the root-candidate stack length at entry. If the expression finishes as +compile-time-known, unconditionally reachable, and effect-free, it removes +child candidates added inside the frame and adds itself. If the expression is +not eligible because of runtime data dependency or effectfulness, its eligible +unconditionally reached child candidates remain. If the expression is not +eligible because it is control-dependent on a runtime branch or match decision, +children inside that conditional region do not add standalone selected roots; +they are evaluated only if an enclosing eligible control expression is +selected. If the expression has delayed effect sources, the checker stores a +tentative parent over its child candidates; effect finalization later keeps the +parent and drops the children when the parent resolves effect-free, or drops +the parent and keeps the children when the parent resolves effectful. This is +the only parent-child replacement rule. There are no special cases for leaves, +strings, numbers, empty lists, records, loops, or other data-expression shapes. +Control-transfer expressions and conditionally evaluated branch regions are +handled by explicit checked control reachability, not by pruning arbitrary +source shapes. + +Delayed parents form intervals over the candidate stack, not source-tree +queries. Nested delayed parents finalize from explicit interval ownership: when +an outer delayed parent is kept, every child candidate in its interval is +removed, including delayed children. When an outer delayed parent is discarded, +the candidates in its interval keep their own finalized results. This preserves +the maximal-root rule without a second walk and without special pruning rules. + +Root selection must be independent of how the source was arranged. A named +top-level value, a closed immutable local value, and an equivalent inline +expression must produce equivalent selected roots once checked dependencies, +checked control reachability, and effects are the same. Selecting a parent root +is the only reason to discard an already selected child root from an +unconditionally evaluated region; rejecting a parent for runtime data +dependency or effectfulness must preserve those eligible children. A +runtime-controlled branch body is different: its contents are not +unconditionally evaluated, so they cannot be selected independently without +explicit checked proof that doing so preserves compile-time observables. + +### Checker Implementation Contract + +The checker has one authoritative state for effect propagation and compile-time +root selection. This state is owned by checking, updated during the existing +`checkExpr` traversal, finalized before checked module output, and exported as +explicit checked data. Canonicalization may produce stable identities and source +structure, but it must not select compile-time roots or decide final +effectfulness. Post-check stages may consume checked roots and evaluated +constants, but they must not repair or reinterpret root eligibility. + +Checking a module-level definition as a dependency is not a child expression of +the lookup that forced it. If a forward reference causes a different +module-level definition to be checked while an expression frame is active, the +checker detaches the root-frame and candidate stacks for that definition. The +definition still writes to the module's shared selected-root, delayed-root, +known-binding, effect-slot, and checked-output state, but its transient +expression frames must not bubble runtime dependency, child candidates, or +last-expression metadata into the forcing lookup. This keeps the result +independent of whether an equivalent top-level constant was checked before or +after the use site. + +Each expression frame records the current root-candidate stack length when the +frame begins. The frame receives child expression summaries as checking +progresses and returns one transient summary to its parent. The summary records +only the data needed by the parent: runtime data dependency, checked control +reachability, and effect state. Ordinary summaries are stack-local. A summary is +stored past the current expression only when a later checked local lookup needs +it, when an effect slot or dispatch watcher must finalize later, or when a +tentative root candidate has been selected. + +Effect propagation uses directed slots and edges: + +- checking a function body, top-level value, `expect` body, or delayed root + candidate creates an effect slot when that boundary needs a checked effect + answer +- a direct call to a known effectful function marks the active slot effectful +- a direct call to a local function with its own slot adds a caller-to-callee + edge +- a call through a function-typed value consumes the checked function effect + kind +- an unresolved static-dispatch call records a watcher from the dispatch + variable to the active slot +- dispatch resolution updates the watched slot from the selected checked method + effect + +Those edges are dependencies, not equality. Recursive groups may be condensed +while solving, but unrelated caller and callee slots must remain one-way. A +slot whose callees and dispatch watchers have not finalized cannot be reported +as definitely pure. Finalization runs after the relevant ordinary type, +literal-defaulting, and static-dispatch constraints have settled; checked +module output must not contain unresolved effect slots or unresolved root +candidates. + +Root selection uses the same expression frames. When a frame finishes as +compile-time-known, unconditionally reached, and effect-free, it replaces the +candidate interval added by its children with the parent candidate. When a +frame finishes as runtime-dependent or effectful, it leaves eligible +unconditionally reached children in place. When a frame is in a branch body, +match guard, or match branch value controlled by a runtime decision, it does +not add selected roots from that conditional region. The enclosing +`if` or `match` may still be selected if the whole control expression becomes +compile-time-known and effect-free. + +Delayed root candidates are tied to effect slots. The candidate stores the +owned interval of child candidates and is finalized from the slot result. If +the slot resolves effect-free, the parent candidate is kept and the child +interval is removed. If the slot resolves effectful, the parent candidate is +discarded and finalized children remain. This interval rule is the only +subsumption rule; implementation must not add leaf filters, observable-effect +filters, or source-shape pruning. + +Compile-time observables are not effect blockers. `crash`, `dbg`, and `expect` +must be represented as ordinary checked expressions for root selection. When +their enclosing selected root is evaluated during checking, they run and report +their diagnostics. An untaken runtime-controlled branch containing those +constructs must not be independently selected, because that would change source +behavior by running compile-time observables that the program would not +evaluate. + +### Compile-Time Evaluation And Static Storage + +Compile-time evaluation must evaluate every checked top-level expression and +every selected compile-time root that can be evaluated without effectful calls +or runtime data. It must run `crash`, `dbg`, and `expect` during that +evaluation and output their diagnostics during `roc check`. + +Evaluation and static storage are separate checked outputs. Unreachable +top-level values are still evaluated when eligible so their `crash`, `dbg`, and +`expect` behavior is reported, but successfully evaluated unreachable data does +not need to be stored in checked module data or target static data. Reachable +evaluated values that have a static representation should be stored once and +shared. Records that contain static lists should point at shared static list +bytes; equivalent named and inline constants should produce equivalent static +data. + +Compile-time evaluation is allowed to fail with user diagnostics only during +checking. After checking, stored constant data is ordinary checked output. A +target static-data builder may decide which reachable evaluated values have a +target representation, but it consumes the checked roots and evaluated values +directly; it must not scan checked CIR or generated code to rediscover root +eligibility. + +If a reachable evaluated value cannot yet be represented as target static data, +the missing representation is a compiler bug. The checked output or static-data +builder must make the missing explicit data assertable and testable; it must +not silently demote the value from compile-time evaluation. No backend may +rediscover or guess root eligibility by scanning source syntax, function +bodies, object symbols, or generated code. + ## Backend Builtins Backend builtin linking is part of backend code generation, not a later repair @@ -123,10 +471,10 @@ payload architecture-specific again. The payloads are built as freestanding LLVM bitcode so compile-time OS and CPU branches cannot bake a native platform's syscalls, inline assembly, or runtime support into a module that will later be retargeted. LLVM object emission for targets that are not required to -link a platform C runtime disables target-library assumptions and lowers LLVM -memory intrinsics to explicit loops before target code generation. macOS and -Windows keep target library calls available because their final links include -the platform runtime libraries. +link a platform C runtime disables target-library assumptions. Targets that +also lack native memory operations lower LLVM memory intrinsics to explicit +loops before target code generation. macOS and Windows keep target library calls +available because their final links include the platform runtime libraries. Builtin definitions in the merged LLVM module are real definitions. They must not be marked `available_externally`, because there is no later builtin object @@ -355,9 +703,9 @@ The term `runtime image` is banned in new post-check docs and code. Use `LirImage` for the contiguous, viewable ARC-inserted LIR image plus layout store and entrypoint tables. -The words `publish` and `fact` are banned in new post-check docs and code, -including their common variants. Use `output` for phase output, or use the -exact owner/data name. +The word `publish` and vague data-owner terms are banned in new post-check docs +and code, including their common variants. Use `output` for phase output, or +use the exact owner/data name. The word `physical` is banned in new post-check docs and code. Use `layout` only for memory shape data such as size, alignment, field offsets, and payload @@ -365,7 +713,7 @@ layout. Use `runtime encoding` for the broader category that includes layouts, discriminants, callable variant encodings, erased callable code entries, ABI shape, and runtime schemas. -The word `artifact` is banned in new post-check docs and code. Use the precise +Vague owner terms are banned in new post-check docs and code. Use the precise owner instead: `CheckedModule`, `CheckedModuleBuilder`, `checked module cache`, checked module data, platform relation data, or another exact producer/consumer name. @@ -919,12 +1267,27 @@ or canonicalization guesses. Allowed dependencies include literals, already known compile-time constants, selected hoisted constants, imported constants whose checked modules have stored values, and pure checked callables whose captures are themselves compile-time-known. Rejected dependencies include -function arguments, runtime pattern binders, mutable locals, effectful calls, -host calls, platform requirements whose values are not available during checking -finalization, and any static dispatch whose checked plan does not identify a -pure compile-time-evaluable operation. Low-level operations may participate only -through explicit checked purity and totality metadata; they must never be -allowed by whitelist, name, or backend knowledge. +function arguments, runtime pattern binders, mutable locals, runtime control +decisions, effectful calls, host calls, platform requirements whose values are +not available during checking finalization, and any static dispatch whose +checked plan does not identify a pure compile-time-evaluable operation. +Low-level operations may participate only through explicit checked purity and +totality metadata; they must never be allowed by whitelist, name, or backend +knowledge. + +Checking errors are dependency-local for hoistability. A malformed expression, +unresolved static-dispatch call, type error, or other checker-owned diagnostic +poisons the expression that owns the error and any expression that explicitly +depends on it. It does not poison sibling definitions, unrelated top-level +values, unrelated imported modules, or the checked program as a whole. A checked +CheckedModule data that carries diagnostics is still a valid input to every independent +compile-time-evaluation decision whose expression/dependency region is +well-checked. If one definition is erroneous and another definition is +independently compile-time-known, the independent definition must still be +evaluated during checking and, when reachable, emitted as static data. +The CheckedModule data must therefore be able to contain both diagnostics and +successful compile-time root requests. The presence of diagnostics is not an +module-level root-selection failure. The compiler must not create separate hoisted roots inside an ordinary top-level constant body. The whole top-level constant body is already a compile-time root, @@ -1184,907 +1547,231 @@ The method registry is an exact table keyed by `(MethodOwner, MethodNameId)`. It is not an owner-discovery mechanism. Post-check code may use it only after a concrete monomorphic dispatcher type has already determined the owner. -Some method registry targets are generated structural targets rather than -procedure bodies. A nominal or opaque type can opt in to a compiler-derived -structural codec with an annotation-only associated method such as -`parser_for : _` or `encoder_for : _`. Canonicalization may represent this marker -as `e_anno_only` or, for hosted/type-module processing, as a zero-argument -`e_hosted_lambda`; `CheckedModule.method_registry` records it explicitly as a -generated parser or generated encoder target. Post-check lowering must consume -that explicit target kind and lower the structural parser/encoder from the -dispatch plan's concrete callable type. It must not treat the marker as a -procedure body, synthesize a fake source function, or infer generated behavior -from a missing procedure template. - -### Structural Serialization Methods - -Parsing and encoding are ordinary static-dispatch methods. Roc does not expose a -builtin `Parser`, `Decoder`, or `Encoding` interface type; the public model is -method-based. - -The performance target is the same shape as hand-written systems parsers: -formats keep input state as cursors and slices, avoid runtime allocation during -parsing, receive the whole requested structural shape before scanning, and lower -to direct calls rather than callback tables, shape interpreters, or temporary -maps built for convenience. The compiler knows Roc structural shapes and method -requirements. It does not know JSON, HTTP headers, CSV, XML, or any other -serialized format. - -A format is ordinary Roc code. Its type owns the methods that describe how that -format reads or writes each shape. Public modules expose small convenience -functions: - -```roc -thing = Json.parse(json_str)? -thing = Json.parse_trailing_commas(json_str)? -thing = Json.Utf8.parse(json_bytes)? - -json_str = Json.to_str(thing) -json_str = Json.to_str_try(floaty_thing)? -json_bytes = Json.Utf8.encode(thing)? - -headers = Encoding.HttpHeader.parse(raw_headers)? -``` - -The convenience functions construct the internal format state directly, call the -value or type's ordinary method, validate the remaining state if the format -requires it, and return the final public value. A fallible helper such as -`Json.to_str_try` returns a `Try` and preserves the encoder's error type. This -is for values that cannot always be represented as JSON, such as `F32` or `F64` -values that are `NaN`, positive infinity, or negative infinity. An infallible -helper such as `Json.to_str` requires an empty encoder error type and returns -the string directly. They do not need a required `init`, `finish`, or `default` -hook. The runtime cursor types are implementation details of the builtin format -module, not public `Json.State` or -`Encoding.HttpHeader.State` APIs. - -The underlying parse method is public and callable. It is deliberately curried: - -```roc -a.parser_for : encoding -> (state -> Try({ value : a, rest : state }, err)) -a.encoder_for : encoding -> (a, state -> Try(state, err)) -``` - -`parser_for` is a method on the value type being produced. `encoder_for` is a -method on the value type being serialized. Structural types get these methods -from the compiler. Nominal types may define them explicitly, and structural -derivation uses those explicit nominal methods when a field, payload, list -element, nested value, or other sub-shape has that nominal type. - -The `encoding` argument is the pure format/configuration value used to construct -the specialized parser. It may represent choices such as JSON object field -renaming, whether JSON accepts trailing commas, JSON tag representation, or a -header matching mode. The `state` -argument is the runtime cursor or output state. Keeping these separate matters: -parser construction can transform the requested structural shape before the -runtime scan starts, while the returned runtime function threads only the cursor -state and parsed values. Encoder construction can similarly precompute -shape-specific metadata before the returned runtime function receives the value -and output state. - -For example, the builtin HTTP header helper inside `Builtin.Encoding` has this -shape: - -```roc -HttpHeader := [MissingRequired, BadHeader].{ - parse : Str -> Try(output, HttpHeader) - where [ - output.parser_for : HttpHeaderEncoding -> (HttpHeaderState -> Try({ value : output, rest : HttpHeaderState }, HttpHeader)), - ] - parse = |raw| { - Output : output - - parse_output = Output.parser_for(HttpHeaderEncoding.Caseless) - parsed = parse_output(HttpHeaderState.{ raw })? - - Ok(parsed.value) - } -} -``` - -The important split is that `Output.parser_for(HttpHeaderEncoding.Caseless)` -constructs the concrete parser and the hidden `HttpHeaderState.{ raw }` is the -runtime input state. Formats with no configurable behavior can still use a -zero-sized internal encoding value. - -The error type is inferred from the format methods. All `Try` errors in one -parse or encode operation unify with the public function's returned error type. -When a concrete encode operation cannot fail, its error type is empty, so -`Json.to_str` can bind the underlying encoder result with an exhaustive -`Ok(encoded_state) = ...` pattern and return `Str` directly. When a concrete -encode operation can fail, `Json.to_str_try` returns `Try(Str, err)` instead. - -Checking derives structural methods by emitting ordinary static-dispatch -constraints. For example, deriving `a.parser_for` for a concrete shape asks the -encoding and state types for exactly the methods needed by that shape: - -- `Str` calls the format's string method; -- records use compiler-generated field sets and the format's record-field - method; -- tag unions call the format's tag-union method with a compiler-generated - tag-union spec; -- lists, numbers, booleans, tuples, and other structural forms call the - corresponding format methods; -- type aliases use their expanded structural shape; -- named nominal values call that nominal type's explicit method. If the method - is missing, checking reports the missing static-dispatch requirement. - -If a format does not support a shape, checking reports the missing method as a -static-dispatch error. Unsupported shapes are not represented as runtime parse -or encode failures. Runtime failures are reserved for input/output conditions -the format can only know while processing bytes or values, such as a malformed -header line, invalid JSON syntax, invalid UTF-8 in a byte input, or a -user-defined nominal method returning an error. - -Compile-time evaluation uses the ordinary Roc constant machinery. The -serialization API does not add a special compile-time marker. A derived -`parser_for` constructs its transformed field sets and nested parsers before it -returns the runtime lambda. If that parser construction is evaluated during -checking, those transformed values are stored as checked constants and restored -later as ordinary Roc values. The returned runtime lambda then closes over only -the transformed field sets and nested parser functions. For a parser constructed -at compile time, original record field names that were renamed during -construction do not need to appear in the final runtime data. - -Tag-union specs are opaque compiler values. They describe the concrete -structural shape being derived: tag names, payload shapes, and the concrete -payload result positions. They are not arity-specific user APIs, and userspace -code does not construct or pattern match on them. The compiler specializes every -use with the concrete tag-union type, so opaque spec operations lower to direct -tag code. - -Userspace format code operates through safe Roc values, opaque specs, opaque -field values, iterators, and slice-returning string/list APIs. The compiler does -not expose raw field-slot indices, unsafe byte indexing, or unchecked memory -primitives as part of the serialization method surface. - -Record parsing is driven by the compiler-generated structural `parser_for` method. -The compiler creates a `Encoding.FieldName.FieldNames(_shape)` value for each -concrete record shape: - -```roc -Encoding.FieldName(_shape) : opaque -Encoding.FieldName.FieldNames(_shape) : opaque - -Encoding.FieldName.FieldNames.rename_fields : Encoding.FieldName.FieldNames(_shape), (Str -> Str) -> Encoding.FieldName.FieldNames(_shape) -Encoding.FieldName.FieldNames.shortest_name : Encoding.FieldName.FieldNames(_shape) -> U64 -Encoding.FieldName.FieldNames.longest_name : Encoding.FieldName.FieldNames(_shape) -> U64 -Encoding.FieldName.FieldNames.iter : Encoding.FieldName.FieldNames(_shape) -> Iter(Encoding.FieldName(_shape)) -Encoding.FieldName.FieldNames.for_size : Encoding.FieldName.FieldNames(_shape), U64 -> Iter(Encoding.FieldName(_shape)) - -Encoding.FieldName.name : Encoding.FieldName(_shape) -> Str -``` - -`Encoding.FieldName.FieldNames(_shape)` contains the requested field names and -compiler-owned result positions for one concrete record shape. -`Encoding.FieldName(_shape)` is an opaque handle to one field in that same shape. The -`_shape` parameter is a phantom type: it is not runtime data, but it ties a -field handle to the exact field set that created it. A parser for -`{ cache_control : Str, content_length : U64 }` cannot accept a -`Encoding.FieldName` produced from `{ foo : Str }`, because the phantom types do not -unify. That type-level tie is what lets generated record parsers avoid runtime -bounds checks on field handles. If the only way to obtain a -`Encoding.FieldName(_shape)` is from the matching -`Encoding.FieldName.FieldNames(_shape)`, then the compiler already knows every handle -is in range for that record. There is no user-exposed `U64` slot to validate at -runtime. - -The derived `parser_for` constructs field metadata before returning the runtime -lambda: - -```roc -renamed_fields = Encoding.FieldName.FieldNames.rename_fields(original_fields, |name| encoding.rename_field(name)) -parse_nested = Nested.parser_for(encoding) -``` - -`encoding.rename_field(name)` is ordinary method-call syntax for a pure format -method whose first argument is the encoding value. Every encoding provides it; -identity is the normal implementation. Taking the encoding value as an argument -lets one encoding type store parser-construction configuration such as JSON -field naming style. `Encoding.FieldName.FieldNames.rename_fields` applies that -function to every requested record field, discards the original names from the -returned `Encoding.FieldName.FieldNames`, and rebuilds the length buckets used by -`Encoding.FieldName.FieldNames.for_size`, `Encoding.FieldName.FieldNames.shortest_name`, -and `Encoding.FieldName.FieldNames.longest_name`. If parser construction is -compile-time evaluated, the renaming work is also compile-time work. For JSON -camel-case decoding, the final runtime parser can contain only `camelCase` -field names. For HTTP header decoding, the final runtime parser can contain only -lowercase kebab-case header names such as `cache-control`. - -Formats expose the methods needed for the shapes they support. A format that can -parse strings, `U64`, tag unions, and records uses these method shapes: - -```roc -encoding.parse_str : encoding, state -> Try({ value : Str, rest : state }, err) -encoding.parse_u64 : encoding, state -> Try({ value : U64, rest : state }, err) -encoding.parse_tag_union : encoding, Encoding.ParseTagUnionSpec(a), state -> Try({ value : a, rest : state }, err) - -encoding.parse_record_field : encoding, Encoding.FieldName.FieldNames(_shape), state -> Try( - [ - Field({ field : Encoding.FieldName(_shape), rest : state }), - TryField({ name : Str, rest : state }), - TryFieldCaseless({ name : Str, rest : state }), - Continue({ rest : state }), - Done({ rest : state }), - ], - err, -) - -encoding.skip_record_field : encoding, state -> Try(state, err) -encoding.missing_record_field : encoding, Str, state -> err -encoding.missing_optional_field : encoding, Str, state -> optional_err -encoding.rename_field : encoding, Str -> Str -``` - -For `Field`, `TryField`, and `TryFieldCaseless`, `rest` is the state positioned -at the field's value. If the field matches the target record, the generated -parser calls the parser for that field's type from that value-start state and -continues from the value parser's returned `rest`. This is what allows records -with different field shapes: - -```roc -{ - content_length : U64, - x_auth_token : Try(Str, [Missing]), - cache_control : Str, -} -``` - -The record loop does not store every value as `Str` first. When it sees the -`content_length` field, it calls the `U64` parser from the value-start state and -continues from that parser's returned state. When it sees `cache_control`, it -calls the `Str` parser. The value parser owns value consumption. - -`Field` means the format already matched the input field name against the -provided `Encoding.FieldName.FieldNames(_shape)`, usually by iterating -`Encoding.FieldName.FieldNames.for_size(fields, len)` -or another field iterator. `TryField` means the format parsed a field name and -asks the generated record parser to exact-match it against the transformed -fields. `TryFieldCaseless` is the same, but uses ASCII caseless matching. If a -`TryField` or `TryFieldCaseless` name does not match any target field, generated -code calls the format's `skip_record_field` method with the encoding and `rest`, -then continues with the returned state. This avoids scanning matched values -twice while still letting unknown fields be skipped correctly. - -`Continue.rest` advances the record loop after the format has consumed input -that cannot be a relevant field. `Done.rest` is the state remaining after the -record ends. If the generated finisher sees that a required field was never -filled, it calls the format's `missing_record_field` method with the encoding, -field name, and final state to produce the format's concrete parse error value. -Optional fields are expressed by their field type, for example -`Try(Str, [Missing])`. If an optional field is absent, the generated finisher -calls the format's `missing_optional_field` method with the encoding, field -name, and final state at the optional field's error type and stores -`Err(missing)` in that field. This lets the format define the absence tag; -`Missing`, `Absent`, or any other tag name is ordinary userspace data, not a -compiler-known concept. A field annotated as `Try(Str, _)` can infer that error -type from the format method's return type. - -Record-field dispatch is optimized around the assumption that serialized record -field names are overwhelmingly small. JSON object keys, HTTP headers, CSV -column names, XML attributes, environment variables, and similar schema fields -are expected to land in Roc's small-string representation almost all the time on -64-bit targets, and still most of the time on 32-bit targets. The optimization -strategy treats this as the hot path, not as a correctness requirement: long -field names remain supported, but generated code is arranged so that small names -take the shortest route. - -Formats own conversion from Roc record field names to serialized field names. -HTTP header parsing can rename `cache_control` to `cache-control` at parser -construction time and then use `TryFieldCaseless("Cache-Control")` at runtime. -JSON camel-case parsing can rename `user_id` to `userId` at parser construction -time and then use `TryField("userId")` at runtime. The compiler does not know -those policies; it only knows that it has a transformed -`Encoding.FieldName.FieldNames(_shape)` value and a requested matching mode. - -`Encoding.FieldName.FieldNames.shortest_name` and -`Encoding.FieldName.FieldNames.longest_name` are computed after renaming. Formats may -use them to skip impossible fields before doing more expensive work. For -example, if a header name is longer than -`Encoding.FieldName.FieldNames.longest_name(fields)` and the format's `rename_field` -never increases field length for headers, the format can consume the line and -return `Continue` without constructing any temporary field name. This is not a -parse failure: for formats such as HTTP headers and JSON objects, unknown fields -remain ordinary input according to that format's rules. If the target record -actually contains a long renamed field name, the long input field remains -matchable through the same `Encoding.FieldName.FieldNames` iteration APIs. - -For small fields, generated record dispatch compares the packed small string -representation directly. Roc zeroes unused SSO bytes, so equality can use -fixed-width word comparisons without masking tail bytes. On 64-bit targets, the -generated dispatcher groups fields into 1-8, 9-16, and 17-23 byte size classes; -on 32-bit targets, the groups are scaled to that target's smaller SSO capacity. -The group selection can be implemented with a branchless or near-branchless -table lookup instead of a source-level length switch. - -Within each size class, the compiler chooses the most discriminating word lane -for the concrete field set. For example, if several fields share the same first -eight bytes, the generated code can use the second or third word as the first -comparison instead. The hot miss path compares one machine word per candidate in -that class. Only after a discriminator hit does the code verify the full SSO key -with one, two, or three word comparisons and dispatch to the matched field's -already-constructed value parser. Collision-heavy classes may use another -discriminating lane or a generated perfect hash over the packed SSO words before -final verification. - -This keeps the performance center on the common case: no heap allocation, no -runtime field map, no interpretation of a record plan, and no byte-by-byte -string comparison unless the selected format's field-name conversion itself -requires it. Long-field paths must preserve the same public behavior and memory -invariants. If a format must handle long fields without allocation, that path -must use field iteration and slice comparisons rather than constructing a -transformed heap `Str`; it is not allowed to make the SSO path slower for the -sake of generality. - -Nested records follow the same construction/runtime split. The outer derived -`parser_for` method eagerly calls every nested parser constructor before -returning its runtime lambda. A nested record gets its own -`Encoding.FieldName.FieldNames(_nested_shape)` value, then renames and rebuckets that -field set through the same `encoding.rename_field` method. A custom nominal -field calls that nominal type's explicit `parser_for` method during parser -construction. At runtime the outer record parser dispatches to the -already-constructed field parser for the matched field shape. - -Tag-union parsing follows the same separation. The format's tag-union method -receives the complete tag spec, identifies the input tag according to that -format's own rules, and uses opaque spec operations to parse and assemble the -selected payload. Recursive tag unions are ordinary recursive method calls -through the selected payload type. The compiler knows the Roc shape and the -static-dispatch requirements; it does not know any format-specific tag -representation. Tag-name renaming can use an analogous construction-time -transformation later; record field renaming does not require the compiler to -know any tag-union convention. - -The generated code uses direct static calls. Tag spec matching is compiler- -generated exact matching over the concrete tag labels; userspace does not pass a -matcher function to spec operations. It does not pass user callbacks, -does not build a runtime interpretation plan, and does not route shape handling -through a central dispatch function. Generic userspace format code produces -record field events, iterates opaque field sets, and calls opaque tag spec -operations. The record loop and field dispatch are compiler-generated for the -concrete shape; tag spec operations are compiler primitives specialized for the -concrete tag-union shape and lower to direct code. - -Input formats return seamless slices whenever the value being produced is a -slice of the original input. Parsing a `Str` from a larger `Str` or validated -byte buffer returns a slice into that buffer when the format can do so. The -format must validate bytes before producing `Str`; `Json.Utf8.parse` validates -string bytes from `List(U8)`, while `Json.parse` starts from an already-valid -`Str`. Hosts that pass request memory to Roc as `Str` must validate that memory -first and keep it alive for the duration of the request. - -The HTTP header format receives only the raw header section, starting at the -first header line and ending before the blank line. Its record-field method -parses one CRLF-delimited line at a time. Each non-empty line must contain `:`; -otherwise the method returns the header format's bad-header error. - -The header encoding's `rename_field` maps Roc field names to lowercase -kebab-case at parser construction time: - -```roc -cache_control -> cache-control -content_length -> content-length -x_auth_token -> x-auth-token -``` - -At runtime the header parser parses the input line name as a seamless slice. It -may use `Encoding.FieldName.FieldNames.for_size` plus ASCII-caseless comparison -against `Encoding.FieldName.name` to match the transformed field set directly and -return `Field({ field, rest: value_start })`. It may also return -`TryFieldCaseless({ name, rest: value_start })` and let generated record -dispatch perform the ASCII-caseless match. If the name cannot match any target -field, the format consumes the line and returns `Continue({ rest: next_line })`. -Matching `Cache-Control`, `cache-control`, and `CACHE-CONTROL` against the -transformed `cache-control` field set does not require allocating a lowercased -copy. Header values are trimmed and passed to field parsing as seamless `Str` -slices. The format does not allocate a header map. - -The JSON `Str` format receives valid UTF-8 text. The JSON `Utf8` format receives -bytes and validates UTF-8 before producing any `Str`. JSON record parsing scans -an object one field event at a time through the compiler-generated record loop, -so object key order does not affect performance beyond normal key matching. A -plain JSON encoding value can use identity `rename_field`. The same JSON -encoding type can carry a camel-case configuration value that renames Roc fields -at parser construction time: - -```roc -user_id -> userId -cache_control -> cacheControl -``` - -The runtime JSON scanner can use `Encoding.FieldName.FieldNames.for_size` and exact -`Encoding.FieldName.name` comparison to match each object key against the -already-renamed field set and return `Field({ field, rest: value_start })` for -known keys. It may also return `TryField({ name, rest: value_start })` and let -generated record dispatch perform exact matching. For unknown keys, it skips the -JSON value according to JSON syntax and returns -`Continue({ rest: after_value })`. The matched field's parser consumes the JSON -value from `value_start`. - -JSON tag unions use the externally tagged representation: - -```json -{ "Admin": { "name": "Sam" } } -``` - -Zero-payload tags encode as the tag string, one-payload tags encode as -`{"Tag":payload}`, and multi-payload tags encode as `{"Tag":[...]}`. This -representation avoids collisions between tag names and ordinary record field -names. Other JSON conventions are represented by different JSON format values -with different methods. The compiler receives the null, missing-field, and -tag-union rules through explicit format methods rather than through hard-coded -JSON syntax recovery. - -Parsing a Roc `Str` from JSON succeeds only for JSON string values. JSON `null` -and missing object fields are separate format conditions. They are surfaced only -through field or value types that request them, such as `Try(Str, [Null])` or -`Try(Str, [Missing])`; the plain `Str` method does not accept either condition. -`Try(a, [Null])` is the nullable JSON value shape. A format's -`missing_optional_field` method chooses the record-field absence tag for -optional fields; JSON uses `Missing`, but another format may choose `Absent` or -any other tag. `Try(a, [Missing])` and `Try(a, [Missing, Null])` are JSON's -record-field-only shapes: missing fields parse as `Err(Missing)`, explicit -`null` parses as `Err(Null)` only when `Null` is in the row, and encoding -`Err(Missing)` omits the field. Missing fields and `Null` are never conflated. - -JSON arrays are used for lists, tuples, and sets. Tuples parse with exact arity. -Sets preserve `Set` insertion order and parse by inserting the array elements. -JSON dictionaries use object representation only when the key type has a -lossless object-key codec: strings, bools, numeric types, and zero-payload tags. -Composite dictionary keys are rejected by static dispatch validation; there is -no automatic pair-array fallback. Dictionary and set encoders do not sort, -because Roc does not require keys or elements to be sortable. - -Concrete HTTP header parser code has this shape inside `Builtin.Encoding`: - -```roc -HttpHeaderState :: { raw : Str } - -HttpHeaderEncoding :: [Caseless].{ - rename_field : HttpHeaderEncoding, Str -> Str - parse_str : HttpHeaderEncoding, HttpHeaderState -> Try({ value : Str, rest : HttpHeaderState }, HttpHeader) - parse_u64 : HttpHeaderEncoding, HttpHeaderState -> Try({ value : U64, rest : HttpHeaderState }, HttpHeader) - - parse_record_field : HttpHeaderEncoding, Encoding.FieldName.FieldNames(_shape), HttpHeaderState -> Try( - [ - Field({ field : Encoding.FieldName(_shape), rest : HttpHeaderState }), - TryField({ name : Str, rest : HttpHeaderState }), - TryFieldCaseless({ name : Str, rest : HttpHeaderState }), - Continue({ rest : HttpHeaderState }), - Done({ rest : HttpHeaderState }), - ], - HttpHeader, - ) - - skip_record_field : HttpHeaderEncoding, HttpHeaderState -> Try(HttpHeaderState, HttpHeader) - missing_record_field : HttpHeaderEncoding, Str, HttpHeaderState -> HttpHeader - missing_optional_field : HttpHeaderEncoding, Str, HttpHeaderState -> [Missing] -} - -HttpHeader := [MissingRequired, BadHeader].{ - parser_for : () -> (Str -> Try(output, HttpHeader)) - where [ - output.parser_for : HttpHeaderEncoding -> (HttpHeaderState -> Try({ value : output, rest : HttpHeaderState }, HttpHeader)), - ] - parser_for = || { - Output : output - parse_output = Output.parser_for(HttpHeaderEncoding.Caseless) - - |raw| { - parsed = parse_output(HttpHeaderState.{ raw })? - Ok(parsed.value) - } - } - - parse : Str -> Try(output, HttpHeader) -} -``` - -The exact derived parser type for a header record with mixed field shapes is: - -```roc -{ - cache_control : Str, - content_length : U64, - x_auth_token : Try(Str, [Missing]), -}.parser_for : HttpHeaderEncoding -> (HttpHeaderState -> Try( - { - value : { - cache_control : Str, - content_length : U64, - x_auth_token : Try(Str, [Missing]), - }, - rest : HttpHeaderState, - }, - Encoding.HttpHeader, -)) -``` - -Because `Encoding.HttpHeader` does not define `parse_tag_union`, trying to parse a -header record that contains a tag union is a compile-time static-dispatch error: - -```roc -bad : Try({ mode : [On, Off] }, Encoding.HttpHeader) -bad = Encoding.HttpHeader.parse("mode: On\r\n") -``` - -The missing requirement is `HttpHeaderEncoding.parse_tag_union`; the compiler -does not wait until runtime to discover that this format does not support tags. - -Concrete JSON parser code has this shape: +### Post-Check Specialization And Iterator Representation -```roc -JsonState :: [Input(Str)] - -JsonEncoding :: [Default, CamelCase, TrailingCommas].{ - rename_field : JsonEncoding, Str -> Str - rename_field = |encoding, name| - match encoding { - Default => name - TrailingCommas => name - CamelCase => snake_to_camel(name) - } - - allows_trailing_commas : JsonEncoding -> Bool - allows_trailing_commas = |encoding| - match encoding { - Default => False - CamelCase => False - TrailingCommas => True - } - - parse_str : JsonEncoding, JsonState -> Try({ value : Str, rest : JsonState }, Json.ParseErr) - parse_record_field : JsonEncoding, Encoding.FieldName.FieldNames(_shape), JsonState -> Try( - [ - Field({ field : Encoding.FieldName(_shape), rest : JsonState }), - TryField({ name : Str, rest : JsonState }), - TryFieldCaseless({ name : Str, rest : JsonState }), - Continue({ rest : JsonState }), - Done({ rest : JsonState }), - ], - Json.ParseErr, - ) - skip_record_field : JsonEncoding, JsonState -> Try(JsonState, Json.ParseErr) - missing_record_field : JsonEncoding, Str, JsonState -> Json.ParseErr - missing_optional_field : JsonEncoding, Str, JsonState -> [Missing] - parse_tag_union : JsonEncoding, Encoding.ParseTagUnionSpec(a), JsonState -> Try({ value : a, rest : JsonState }, Json.ParseErr) -} +There is one production checked-to-LIR route for every execution and code +generation mode: -Json :: {}.{ - ParseErr : [MissingRequiredField(Str), InvalidJson(Str)] - - parse : Str -> Try(a, Json.ParseErr) - where [ - a.parser_for : JsonEncoding -> (JsonState -> Try({ value : a, rest : JsonState }, Json.ParseErr)), - ] - parse = |json| { - Shape : a - parse_shape = Shape.parser_for(JsonEncoding.Default) - parsed = parse_shape(JsonState.Input(json))? - - match parsed.rest { - Input(rest) => - if Str.is_empty(Str.trim_start(rest)) { - Ok(parsed.value) - } else { - Err(InvalidJson("Invalid JSON")) - } - } - } - - parse_trailing_commas : Str -> Try(a, Json.ParseErr) - where [ - a.parser_for : JsonEncoding -> (JsonState -> Try({ value : a, rest : JsonState }, Json.ParseErr)), - ] - parse_trailing_commas = |json| { - Shape : a - parse_shape = Shape.parser_for(JsonEncoding.TrailingCommas) - parsed = parse_shape(JsonState.Input(json))? - - match parsed.rest { - Input(rest) => - if Str.is_empty(Str.trim_start(rest)) { - Ok(parsed.value) - } else { - Err(InvalidJson("Invalid JSON")) - } - } - } - - parser_camel : () -> (Str -> Try(a, Json.ParseErr)) - where [ - a.parser_for : JsonEncoding -> (JsonState -> Try({ value : a, rest : JsonState }, Json.ParseErr)), - ] - parser_camel = || { - Shape : a - parse_shape = Shape.parser_for(JsonEncoding.CamelCase) - - |json| { - parsed = parse_shape(JsonState.Input(json))? - - match parsed.rest { - Input(rest) => - if Str.is_empty(Str.trim_start(rest)) { - Ok(parsed.value) - } else { - Err(InvalidJson("Invalid JSON")) - } - } - } - } -} +```text +checked modules + -> Monotype + -> Monotype Lifted + -> optional Monotype Lifted SpecConstr + -> lifted capture recomputation + -> Lambda Solved + -> explicit solved inline plan + -> direct SolvedLirLower + -> TRMC, join scalarization, box reuse, return-slot rewriting + -> optional tag reachability + -> reachable-procedure pruning + -> ARC insertion + -> backend, interpreter, or LirImage ``` -The exact derived parser type for a JSON record is: +`src/lir/checked_pipeline.zig` owns this order. Dev and interpreter builds do +not take a separate Lambda Mono or LIR-lowering route. Size and speed builds do +not bypass Lambda Solved. All modes therefore consume the same Monotype type +identities, the same Lambda Solved callable information, and the same direct +Solved-to-LIR representation decisions. -```roc -{ - cache_control : Str, - nested_record : { inner_value : Str }, - user_id : Str, -}.parser_for : JsonEncoding -> (JsonState -> Try( - { - value : { - cache_control : Str, - nested_record : { inner_value : Str }, - user_id : Str, - }, - rest : JsonState, - }, - Json.ParseErr, -)) -``` +The explicit `InlineMode` controls the optional specialization work: -The exact derived parser type for an externally tagged JSON union is: +- `.none` skips Monotype Lifted SpecConstr and produces an empty solved inline + plan. Dev and interpreter modes select this. +- `.wrappers` runs SpecConstr and produces wrapper-inline decisions from + Lambda Solved. Size and speed modes select this. +- optimized eval and focused lowering tests may select `.wrappers` directly. -```roc -[Admin({ name : Str }), Guest].parser_for : JsonEncoding -> (JsonState -> Try( - { - value : [Admin({ name : Str }), Guest], - rest : JsonState, - }, - Json.ParseErr, -)) -``` +The mode is compiler input supplied to the checked pipeline. SpecConstr and the +solved inline analyzer consume it directly. They do not infer optimization mode +from the target, backend, symbol names, builtin names, or emitted code. The mode +changes optimization work, not source meaning or the stage route. -With `JsonEncoding.Default`, this parses values like: +`SolvedLirLower` computes the logical Lambda Mono callable, capture, procedure, +and function-free type decisions while directly consuming Lambda Solved syntax. +Release builds do not materialize a second Lambda Mono expression, pattern, +statement, or local tree. Debug builds separately materialize Lambda Mono and +compare its decisions with the direct lowerer; that verifier is not a production +lowering route. -```json -{ "Admin": { "name": "Sam" } } -{ "Guest": {} } -``` +#### Public Iterator Contract -A custom nominal type can define `parser_for` manually and remain polymorphic -over any encoding that supplies the methods it uses. This does not auto-derive -the nominal type; it is an ordinary method the user wrote: +`Iter` and `Stream` remain public Roc builtins with their existing source +types: ```roc -Token := { raw : Str }.{ - parser_for : encoding -> (state -> Try({ value : Token, rest : state }, err)) - where [ - encoding.parse_str : encoding, state -> Try({ value : Str, rest : state }, err), - ] - parser_for = |encoding| { - Encoding : encoding - - |state| { - parsed = Encoding.parse_str(encoding, state)? - Ok({ value: Token.{ raw: parsed.value }, rest: parsed.rest }) - } - } +Iter(item) :: { + len_if_known : [Known(U64), Unknown], + step : () -> [One({ item, rest : Iter(item) }), Skip({ rest : Iter(item) }), Done], } -``` - -An encoding type can also be the runtime state type. There is no requirement to -invent a separate `State` type if the format state naturally belongs in the -encoding value: -```roc -TinyText :: [Input(Str), Done].{ - rename_field : TinyText, Str -> Str - rename_field = |_, name| name - - parse_str : TinyText, TinyText -> Try({ value : Str, rest : TinyText }, [MissingRequired]) - parse_str = |_, state| - match state { - Input(value) => Ok({ value, rest: Done }) - Done => Err(MissingRequired) - } -} - -parse_token : TinyText -> Try(Token, [MissingRequired]) -parse_token = |input| { - parse = Token.parser_for(input) - parsed = parse(input)? - Ok(parsed.value) +Stream(item) :: { + len_if_known : [Known(U64), Unknown], + step! : () => [One({ item, rest : Stream(item) }), Skip({ rest : Stream(item) }), Done], } ``` -Encoding is symmetric. Structural `encoder_for` methods call the format's output -methods for strings, records, tag unions, lists, and other shapes. A format's -output state owns whatever builder it needs. JSON encoding to `Str` allocates -the final string in the ordinary way, and JSON UTF-8 encoding produces -`List(U8)`. Formats whose serialization can fail express that through the same -inferred `Try` error type as parsing. - -The public structural encode method has this exact shape: - -```roc -value.encoder_for : encoding -> (value, state -> Try(state, err)) -``` - -Generated encoders compose child error rows. JSON helpers that cannot fail use a -named `_never_fails` row variable so they can sequence with encoders that can -fail. `Json.to_str` requires the final structural encoder's error type to be the -empty row `[]`; `Json.to_str_try` preserves the final structural encoder's error -type as `Try(Str, err)`. JSON `F32` and `F64` encoders are the deliberate failing -scalar case: finite values encode as JSON numbers, while `NaN`, positive -infinity, and negative infinity return `Err(NaN)`, `Err(Infinity)`, or -`Err(NegativeInfinity)`. They must not encode non-finite values as JSON `null`, -and they do not satisfy `Json.to_str`'s infallible encoder requirement. +Adapters, custom sources, and consumers remain ordinary Roc functions. There is +no public chain type, iterator trait, extra public step tag, or source-visible +compiler representation. Internal representation data is attached only after +checking, when Monotype creates concrete iterator call results. -For a concrete record, the compiler can derive: - -```roc -{ - count : U64, - foo_bar : Str, -}.encoder_for : MyEncoding -> ({ count : U64, foo_bar : Str }, MyState -> Try(MyState, MyErr)) -``` - -The encoding type owns the output methods required by that shape: - -```roc -MyEncoding :: [Out(Str)].{ - rename_field : MyEncoding, Str -> Str - encode_record : - MyState, - U64, - (MyState, (MyState, Str, (MyState -> Try(MyState, MyErr)) -> Try(MyState, MyErr)) -> Try(MyState, MyErr)) - -> Try(MyState, MyErr) - encode_str : Str, MyState -> Try(MyState, MyErr) - encode_u64 : U64, MyState -> Try(MyState, MyErr) -} -``` +#### Explicit Iterator Representation Tiers -The `U64` argument is the statically-known number of record fields that will be -encoded. The callback receives the current state and a field writer supplied by -the format. Generated record encoders call the field writer once per present -field: +A Monotype named type definition records an explicit iterator representation +decision: -```roc -MyEncoding.encode_record( - state, - 2, - |state0, field| { - state1 = field(state0, "count", |s| encode_count(value.count, s))? - field(state1, "foo-bar", |s| encode_foo_bar(value.foo_bar, s)) - }, -) -``` - -Tuples and lists use the same ownership pattern, except their writer has no -field name: - -```roc -encode_tuple : - MyState, - U64, - (MyState, (MyState, (MyState -> Try(MyState, MyErr)) -> Try(MyState, MyErr)) -> Try(MyState, MyErr)) - -> Try(MyState, MyErr) - -encode_list : - MyState, - U64, - (MyState, (MyState, (MyState -> Try(MyState, MyErr)) -> Try(MyState, MyErr)) -> Try(MyState, MyErr)) - -> Try(MyState, MyErr) -``` - -### Compile-Time Literal Conversions - -A numeric literal whose target type is a non-builtin nominal type converts -through that type's **own declared** `from_numeral` method, and a string literal -converts through its declared `from_quote` (receiving the literal's post-escape -contents as `Str`). The conversion is not inherited through the backing chain: a -transparent newtype that declares no `from_` does not accept a bare literal — -that is a type error; use explicit `Nominal.(value)` construction instead. -Every such conversion with a concrete target type is a -compile-time root (`numeral_conversion` / `quote_conversion`), no matter -where the literal sits in the AST: checking finalization evaluates the raw -dispatch call, stores its `Try` result through `ConstStore`, unwraps `Ok` into -the literal's stored constant, and reports `Err(InvalidNumeral(msg))` / -`Err(BadQuotedBytes(msg))` as a checking problem carrying the implementation's -message. Runtime lowering restores the stored constant instead of emitting a -call. Conversions whose target type is still polymorphic (literals inside -generalized functions) keep the dispatch call per monomorphic specialization. - -Unresolved literal-origin type variables default — numerals to `Dec`, quotes -to `Str`. Quote defaulting runs before numeric context resolution because a -still-flex string receiver blocks the method chains that give numeric literals -their context, and it also resolves generalized literal variables that no -instantiation can pin, which is the same resolution monomorphic specialization -would apply, made early enough for checking to resolve dependent dispatch. -Generalized function signatures are different: a still-flex literal-origin or -method-constrained argument in a polymorphic function is a valid contract, not a -def-site error. Checking must not validate those generalized constraints against -`Dec` or `Str` at the function definition. Each instantiated copy is defaulted -and validated only after call-site evidence has had a chance to pin it. - -Literal patterns participate through the same machinery. A literal pattern on -a non-builtin number or string type carries a synthesized checked conversion -expression; match lowering binds the matched value and tests it against the -converted constant, dispatching to the type's `is_eq` method when it has one -and using derived `is_eq` otherwise — exactly mirroring `==`. Checking -attaches an `is_eq` constraint to the pattern's type so this lowering is -total. Literal patterns on builtin types keep their direct literal-pattern -encoding. - -### Constructing Nominal Values - -Nominal construction is expression-directed, not a unification side effect. -Explicit construction syntax — `Type.(value)`, `Type.(a, b)`, `Type.{ field }`, -`Type.Tag(payload)` — canonicalizes to a single `e_nominal` (or -`e_nominal_external`) expression whose `backing_type` records the surface form -(`value`, `tuple`, `record`, `tag`). Checking instantiates the nominal -declaration, unifies the user-written backing expression against the -declaration's backing variable, and gives the whole expression the nominal type. -The backing variable is read **only** during this unification; nominal identity -is defined by origin, name, and arguments, and two nominals of different identity -never unify. A value already typed as a different nominal or primitive therefore -does not silently lift into a nominal — the implicit path is reserved for -literals, which convert through the `from_numeral` / `from_quote` mechanism above -(walking transparent newtype chains to reach the builtin backing). - -### String Interpolation - -An interpolated string literal is its own CIR expression. It is not -desugared as receiver method-call syntax, because interpolation method -selection is owned by the expression result type, not by the first literal -segment. The interpolated expressions bind to locals in source order. Literal -segments are always builtin `Str` values, and the interpolation expression -passes the first segment plus an `Iter((interpolated, Str))` of the remaining -interpolated values paired with the literal segment that follows each one. - -For an unsuffixed interpolation, checking gives the expression this type: - -```roc -val where [ - val.from_interpolation : Str, Iter((_interpolated, Str)) -> val, -] -``` - -The static dispatch owner is `val`, the interpolation result type. If `val` -remains unconstrained, it defaults to `Str`, which selects: - -```roc -Str.from_interpolation : Str, Iter((Str, Str)) -> Str -``` - -Types that want checked interpolation through `Try` implement their own -`from_interpolation` and rely on `Try` forwarding: +```zig +const IteratorRepresentation = enum(u8) { + none, + minted, + forced_dynamic, +}; -```roc -Try.from_interpolation : Str, Iter((interpolated, Str)) -> Try(ok, err) - where [ - ok.from_interpolation : Str, Iter((interpolated, Str)) -> Try(ok, err), - ] +const TypeDef = struct { + // declaration identity fields + generated: ?TypeDigest = null, + iterator_representation: IteratorRepresentation = .none, + iterator_depth: u8 = 0, +}; ``` -For a suffixed interpolation such as `"a${x}b".Regex`, the suffix is not a -static-dispatch owner. It is a direct associated-function call to -`Regex.from_interpolation`; the function's argument types constrain the -literal segments and interpolated expressions, and the function's return type is -the type of the whole interpolation expression. Missing suffixed interpolation -functions are reported as missing associated functions on the resolved suffix -target. - -Interpolation deliberately does not parameterize literal segments over an -arbitrary `literal` type with a `literal.from_quote` constraint. That design -would defer quoted-segment conversion errors until monomorphic specializations -are known. `roc check` must report all compile-time conversion errors without -monomorphizing the program, so interpolation segments use builtin `Str` -directly. Normal non-interpolated quoted literals still convert through -`from_quote` as described above. +The fields have these meanings: + +- `none` is the ordinary public nominal. It carries no internal chain identity. +- `minted` is a statically bounded internal chain representation. + `generated` is its chain/callable-evidence digest and `iterator_depth` is + the producer-computed chain depth. +- `forced_dynamic` is the explicit fixed-point representation selected at the + mint-depth boundary. It retains the public declaration identity while the + representation field keeps it distinct from the ordinary public nominal. + +These fields participate in named-type equality, cross-store equality, and type +digests. Every type-store translation copies them. A later stage never derives a +tier or mint depth from lowered type shape. + +For a minted iterator, Monotype rewrites the public recursive `rest` type in the +step result to the minted self type and records concrete adapter components as +additional nominal arguments. Each adapter layer therefore embeds its concrete +predecessor by value. A bounded chain is a finite tower of distinct nominal +identities rather than one public nominal with a recursive self edge. + +The representation producer is `generatedIteratorType` in +`src/postcheck/monotype/lower.zig`. It computes: + +- source depth 1; +- adapter depth as one plus the maximum minted depth reachable by value through + its components; +- a hard minted depth limit of 16; +- a structural-walk budget of 64. + +A `minted` child contributes its recorded depth. A `forced_dynamic` child +contributes the cap, so every adapter above it remains dynamic. Ordinary named +arguments, records, tuples, tag payloads, lists, and boxes propagate the maximum +depth of values they contain. Function types do not contribute stored chain +depth, and named backings are not traversed. + +If the next chain would exceed the limit, Monotype interns one +`forced_dynamic` iterator type per item-type digest. Its public-shaped backing +is recursively rewritten to its own type, giving recursive construction a +finite type fixed point. The bounded walk reports the cap when its own budget is +exhausted, so exhaustion selects the explicit dynamic tier rather than allowing +the minted type universe to grow without bound. + +This cap is a type-universe bound, not a call-depth or specialization-request +counter. Every path that mints an iterator passes through the same producer, so +recursive functions, loops, and ordinary calls all receive the same finite +representation decision. + +#### Tier Unification And Callable Flow + +Monotype instantiation and Lambda Solved unification consume the representation +tier explicitly: + +- a forced-dynamic iterator wins when related to a minted or ordinary public + iterator with the same source declaration and item type; +- a minted iterator wins when related to its ordinary public source type; +- distinct minted iterator identities join their item and backing information + without discarding callable members; +- equal tiers use ordinary named-type equality. + +At a forced-dynamic relation, Lambda Solved unifies the item type and both +backings before linking the other type to the dynamic root. This is what carries +every reachable finite step implementation into the dynamic callable set. + +When a complete Monotype type clone contains a forced-dynamic iterator, +Lambda Solved marks the callable in that iterator's backing as erased. The mark +runs only after the clone is structurally complete, so the erased callable's +source-function digest never observes a partially built type. The erased +callable then accumulates exact finite members through normal Lambda Solved +unification. + +Minted iterator backings keep finite callable slots inline. Only +forced-dynamic backings take this explicit erased-callable boundary. The direct +LIR lowerer dumbly consumes the solved result: finite callables become generated +tag-union values; erased callables become packed erased-callable values and +indirect calls. It does not apply iterator depth policy or repair callable +variant sets. + +#### SpecConstr And Loop Scalarization + +Monotype Lifted SpecConstr is a general call-pattern specialization pass. It +runs only when `InlineMode` is not `.none`. It consumes explicit lifted +constructor and callable values and creates workers whose arguments are the +parts the callee immediately observes. + +Iterator and stream loops are important clients. After wrapper inlining exposes +a known iterator constructor, SpecConstr can: + +- split known record, tuple, tag, nominal, and callable arguments into leaves; +- redirect matching direct calls to specialized workers; +- simplify field reads and matches from known values; +- scalarize loop-carried constructor state; +- supply each reachable `continue` edge with the scalar leaves required by the + loop fixed point. + +Iterator classification in this pass consumes the explicit iterator +representation field (or the checked public `Builtin.Iter` identity). It does +not identify generated iterator types solely from a nullable generated digest. + +SpecConstr is not responsible for making bounded iterator representation +allocation-free. Per-chain minting removes the recursive layout edge in every +mode. SpecConstr improves optimized loop and call shape so later lowering and +LLVM see scalar state and direct operations. + +#### Constant Storage + +Compile-time finalization is separate from iterator representation and +SpecConstr. Eligible constant list values become explicit +`static_data_candidate` nodes, and direct LIR lowering emits their bytes into +the data segment. This is why a constant list consumed through `.iter()` can +have zero runtime list allocation in a size cart even though the eval allocation +harness, which does not perform final constant hoisting, observes one base-list +allocation. + +#### Correctness Boundaries + +All modes must preserve the same observable Roc behavior. The optional +specialization mode may change compile-time work and generated shape only. + +The following properties require focused tests: + +- bounded iterator chains remain minted and have no iterator-attributable box or + erased callable; +- recursive and over-cap chains terminate and use the forced-dynamic callable + representation; +- forced-dynamic callable sets contain every member that can reach the boundary; +- wrapper mode and ordinary mode agree on values and effects; +- iterator loop scalarization preserves every reachable transition, including + adapter-state changes across `continue` edges; +- constant-list zero-allocation claims are tested on a cart path that performs + compile-time finalization; +- direct Solved-to-LIR decisions agree with the debug materialized Lambda Mono + verifier. + +Backends receive only ordinary LIR and explicit ARC statements. They must not +know whether a value originated as a public iterator, a minted iterator, +forced-dynamic callable state, or a scalarized loop. ## Shared Post-Check Model @@ -2111,7 +1798,9 @@ Type-store ownership is explicit at each stage boundary: - Monotype Lifted IR uses the same Monotype type store because lifting does not change types. - Lambda Solved IR owns a new type store with lambda-set variables. -- Lambda Mono owns a new type store with no function types. +- Direct Solved-to-LIR lowering owns its function-free logical Lambda Mono type + decisions; only the debug verifier materializes the corresponding Lambda Mono + store. - LIR owns committed layouts, not post-check type ids. A later stage must not reinterpret an earlier stage's type ids unless the stage @@ -2129,8 +1818,8 @@ stage owns, such as: - symbol to local environment - source procedure to specialization record - type id to already-lowered type id -- Lambda Mono type id to committed LIR layout id -- Lambda Mono procedure id to LIR procedure id +- direct-lowering type decision to committed LIR layout id +- direct-lowering procedure decision to LIR procedure id Those tables are not hidden checked-data side channels. They must not contain data that are missing from the produced IR. If deleting a table would make it @@ -2179,13 +1868,21 @@ const MonoType = union(enum) { erased: TypeDigest, }; -const CheckedModuleId = enum(u32) { _ }; -const TypeDefId = enum(u32) { _ }; const TypeDigest = struct { bytes: [32]u8 }; const TypeDef = struct { - module: CheckedModuleId, - id: TypeDefId, + module: ModuleIdentityId, + type_name: TypeNameId, + source_decl: ?u32, + generated: ?TypeDigest, + iterator_representation: IteratorRepresentation, + iterator_depth: u8, +}; + +const IteratorRepresentation = enum(u8) { + none, + minted, + forced_dynamic, }; const Fn = struct { @@ -3095,7 +2792,8 @@ const LiftedFn = struct { Function references remain ordinary values with function type. A function value is not packed here. Captures are explicit metadata on the lifted function -definition; callable representation is not chosen until Lambda Mono. +definition; callable flow is solved in Lambda Solved and runtime representation +is committed by direct LIR lowering. The lifting pass owns free-variable analysis. It does not choose finite callable representations, erased callable representations, closure object @@ -3200,8 +2898,9 @@ output, and no representation recovery later. ### Erased Callable Requirements -`erased` callable requirements are explicit data entering Lambda Solved IR. -They are not inferred from backend needs or recovered from runtime encodings. +`erased` callable requirements are explicit data entering or produced at the +Lambda Solved boundary. They are not inferred from backend needs or recovered +from runtime encodings. The producers are: @@ -3214,25 +2913,30 @@ The producers are: callable parameter or result - checked root ABI metadata for values that will later be consumed by LirImage or glue, when that metadata explicitly names erased callable slots +- a Monotype iterator type explicitly marked `forced_dynamic`, whose recursive + step callable crosses the dynamic representation boundary -Monotype lowering carries these requirements as typed checked annotations into -Lambda Solved IR. Lambda solving unifies them through the same function +Monotype lowering carries boundary requirements as typed annotations into +Lambda Solved IR. Lambda solving also consumes the explicit iterator +representation tier and marks a completed forced-dynamic backing callable as +erased. It unifies all requirements through the same function `args/callable/ret` graph used for finite lambda sets. If a callable slot is -forced to `erased`, Lambda Mono lowering produces packed erased callable values +forced to `erased`, direct LIR lowering produces packed erased callable values and indirect erased calls. If no explicit erased requirement reaches a callable slot, finite lambda-set dispatch is used. -No ordinary source expression becomes erased because a later stage finds finite -dispatch inconvenient. Erasure is introduced only by one of the checked boundary -data above. +No ordinary source expression becomes erased because direct lowering finds +finite dispatch inconvenient. Erasure is introduced only by explicit checked +boundary data or the explicit Monotype forced-dynamic iterator tier. -## Lambda Mono Decisions +## Direct LIR Callable Decisions -Lambda Mono consumes Lambda Solved IR and chooses function-free callable, -procedure, capture, and type representation data. These decisions are explicit -stage output, but release builds do not store a full Lambda Mono expression, -pattern, or statement tree. The direct LIR builder consumes the Lambda Solved -lifted syntax together with Lambda Mono decision tables. +`SolvedLirLower` consumes Lambda Solved IR and chooses function-free callable, +procedure, capture, and type representation data while lowering directly to +LIR. This section calls those choices the logical Lambda Mono decisions because +the debug verifier materializes the Lambda Mono program that represents them. +In release builds they are lowerer-owned data, not a separate stage output, and +there is no stored Lambda Mono expression, pattern, or statement tree. The Lambda Mono type store has no function type. Function values have already become ordinary value representations: @@ -3285,8 +2989,8 @@ capture record with those exact slots and stores it in the callable value. It does not use the source function's own function type as a proxy for the expression-site callable type. -The release-build Lambda Mono output contains only data that later stages must -consume explicitly: +The release-build direct lowerer owns only the decision data needed to produce +LIR: - the function-free Lambda Mono type store - queued function specializations keyed by exact lifted function id, solved @@ -3304,9 +3008,9 @@ the original lifted node. When Lambda Mono changes behavior, direct LIR lowering uses the explicit Lambda Mono decision associated with that expression, call, function reference, captured local, or callable pattern. -This keeps Lambda Mono as a real compiler stage without making the normal -pipeline pay for a second syntax arena that mostly duplicates Monotype Lifted -IR. +This keeps the logical Lambda Mono contract explicit without making the +production pipeline pay for a second syntax arena that mostly duplicates +Monotype Lifted IR. ### Logical Lambda Mono Expressions @@ -3398,10 +3102,12 @@ after-the-result conversion. ## Direct LIR Lowering -LIR lowering consumes Lambda Solved lifted syntax plus Lambda Mono decision -tables directly. It is the only production path from Lambda Solved to LIR. +LIR lowering consumes Lambda Solved lifted syntax plus the explicit solved +inline plan. It computes and consumes the logical Lambda Mono decisions inside +the same direct builder. This is the only production path from Lambda Solved to +LIR. -There is no separate stored layout IR. The Lambda Mono to LIR builder owns: +There is no separate stored layout IR. The direct Solved-to-LIR builder owns: - a layout builder that interns and commits recursive layouts from Lambda Mono type nodes @@ -3424,8 +3130,8 @@ The builder may maintain temporary maps such as `TypeId -> layout.Idx`, `LambdaMonoFnId -> LirProcSpecId`, `LiftedLocalId -> LirLocalId`, and `LiftedExprId -> lowered logical expression` while lowering one function specialization. These maps are caches of work the builder owns. They must not -contain checked data that are absent from Lambda Solved IR, Lambda Mono -decisions, or the LIR result. +contain checked data that are absent from Lambda Solved IR, the explicit inline +plan, the builder's logical Lambda Mono decisions, or the LIR result. Release builds must not allocate, fill, traverse, or validate a materialized Lambda Mono expression, pattern, or statement tree. Release builds may allocate @@ -4219,6 +3925,94 @@ itself is the only holder of the buffer (the runtime count of 1 proved there were no other counted handles, and a live borrow of the list would have forced the copy path through an owned capture's incref). +### Destination-Passing Results and Allocation Reuse + +Large result values should be lowered by destination demand rather than by +building a temporary value and then copying it into its final storage. A +destination demand is explicit LIR producer input, not backend policy. It +describes where a result should be written and which existing allocation, if +any, may be reused when ARC proves or checks uniqueness. Backends, the +interpreter, and LirImage consume the resulting LIR mechanically. + +The direct LIR builder may create a small bounded set of result variants for a +proc: + +- `return_slot(T)`: write a by-memory result into caller-provided `ptr(T)`. +- `reuse_box(T)`: consume `Box(T)` and use its payload storage as the result + destination when uniqueness permits. +- `reuse_erased_callable`: consume an erased callable allocation and overwrite + its function pointer, drop callback, and capture bytes when uniqueness and + payload layout permit. +- `append_into(Str)` / `append_into(List(T))`: build a returned string or list + by appending into a caller-provided unique accumulator. + +These variants are keyed by proc id, result demand, and committed layouts. +Identical keys share one variant. Root procs and ABI-pinned procs keep their +ordinary signature; wrappers may call an internal destination variant, but the +ABI-facing signature is not changed by this optimization. + +`return_slot(T)` is selected by layout representation, not by source syntax. +Scalar, pointer, and zero-sized result layouts keep ordinary returns. +By-memory result layouts may be emitted as `proc(out: ptr(T), args...) -> {}` +inside the LIR program. Existing ABI lowering remains responsible for adapting +that internal shape to whatever a target ABI requires at roots and host +boundaries. + +`reuse_box(T)` models the common shape: + +```roc +Box.box(f(Box.unbox(boxed))) +``` + +as an allocation-reuse operation over one consumed `Box(T)`. The operation's +RC metadata consumes the box argument, may runtime-check its uniqueness, and +returns the same outer allocation on the reuse path. ARC may set the statement's +`unique_args` bit when the runtime check is proven redundant by the existing +born-unique and no-live-borrow rules. When the check is not redundant, the +runtime check remains. If the box is not unique at runtime, the operation takes +the defined copy path and returns a fresh box. The payload move, replacement, +and old-payload release are all explicit in LIR or in the low-level operation's +documented RC effect; no backend may infer them from `Box` names or pointer +shapes. + +`reuse_erased_callable` is the erased-callable counterpart. Erased callables are +not ordinary `Box(T)` payloads; their allocation stores a callable entry, an +optional drop callback, and inline capture bytes. Reuse is allowed only when: + +- the old erased callable is consumed and unique, or its runtime uniqueness + check succeeds +- the new callable payload has the same committed payload size and alignment + class as the old allocation, in the initial design +- the old capture payload is released by the old drop callback before the + allocation is overwritten +- the new callable entry, new drop callback, and new capture bytes are written + before the result is returned + +The first implementation should require same-size/same-alignment erased +callable payloads. Broader reuse across different capture layouts requires an +explicit capacity or size input; it must not be guessed from the erased function +type alone, because an arbitrary `Box(a -> b)` value does not identify the +stored capture layout. + +Destination-aware aggregate construction is required for the full benefit of +box reuse. A record update or tag construction whose result is demanded in a +slot should write fields and discriminants into that slot rather than first +forming a whole temporary aggregate. If the destination aliases a consumed +input, lowering must preserve read-before-overwrite order: fields needed later +are moved or copied to temporaries before their slots are overwritten, and every +refcounted field is moved, retained, or released exactly once. This ordering is +part of LIR construction and ARC emission, not backend cleanup. + +Append destinations are result demands for producer functions that return +`Str` or `List(T)`. Under `append_into(Str)`, string literals, string slices, +string concatenations, and direct calls to append-capable producers write into +the supplied unique string accumulator. Any expression that cannot append +directly is first lowered to an ordinary result and then appended as an +explicit step. `append_into(List(T))` follows the same rule for list builders. +These variants are created only for realized demands and are keyed by result +kind and element layout, so specialization is bounded by the distinct demands +the program actually uses. + Each stage fully replaces the previous behavior when it lands; there are no parallel insertion paths at any point: @@ -4247,8 +4041,10 @@ checked CIR -> CheckedModuleBuilder during checking finalization -> Monotype IR -> Monotype Lifted IR + -> optional SpecConstr -> Lambda Solved IR - -> Lambda Mono decisions + -> solved inline plan + -> direct Solved-to-LIR decisions -> LIR -> ARC insertion -> native dev backend on native compiler hosts @@ -4573,7 +4369,11 @@ targets: { inputs_dir: "targets/", arm64mac: { inputs: ["libhost.a", app], output: Shared }, x64glibc: { inputs: ["libhost.a", app], output: Exe }, - wasm32: { inputs: ["host.wasm", app], output: Shared }, + wasm32: { + inputs: ["host.wasm", app], + output: Shared, + exports: ["start", "update"], + }, } ``` @@ -4602,6 +4402,23 @@ The output that static archives previously stood in for on wasm (a linked, loadable, no-entry module) is `Shared`, not `Archive`; `Archive` is never a linked module. +For wasm targets, `exports:` is the complete final host-visible function ABI. +Every named function is a link root and is emitted in the module export +section; no other host function becomes public. An explicitly empty list means +that the final module has no exported functions. Omitting the field preserves +compatibility with older platforms by exporting the public function symbols +found in their wasm object inputs. New platforms should always declare the +field so object visibility cannot accidentally enlarge the final ABI or retain +link-only code. + +After the final wasm link, size builds run Binaryen at optimize level 2 and +shrink level 2, validate the resulting module, and remove debug, producer, and +target-feature custom sections from non-debug output. Removing the +target-feature custom section does not alter the wasm code section; Binaryen +validates the module after the metadata is removed. The removed custom section +is not part of the runtime ABI. Debug builds retain debugging and +target-feature metadata. + ## Host Symbol ABI Hosts and compiled Roc code share symbols resolved at link time; there is no @@ -4841,8 +4658,8 @@ Roc's checked module boundary and existing LIR. | `monotype` | Monotype IR | | `monotype_lifted` | Monotype Lifted IR | | `lambdasolved` | Lambda Solved IR | -| `lambdamono` | Lambda Mono decisions | -| `ir` | direct Lambda Mono to LIR builder | +| `lambdamono` | logical Lambda Mono decisions in direct lowering; materialized only by the debug verifier | +| `ir` | direct Solved-to-LIR builder | | `eval` | LIR interpreter for compile-time evaluation | Roc intentionally keeps Cor's post-solve shape: @@ -4928,7 +4745,7 @@ The allowed replacement is explicit stage ownership: - Monotype owns monomorphic specialization and static-dispatch elimination - Monotype Lifted owns closure lifting - Lambda Solved owns callable flow in the type graph -- Lambda Mono owns explicit callable value representation +- direct Solved-to-LIR lowering owns explicit callable value representation - LIR lowering owns committed layouts and statement lowering - ARC owns borrow inference, mode specialization, and reference-count insertion @@ -4950,13 +4767,13 @@ Minimum boundary checks: definitions in expression position, definition references in expression position, or direct calls whose callee is still a Monotype function template. - Lambda Solved IR has every function type in `args/callable/ret` form. -- Lambda Solved IR has no unresolved callable slot before Lambda Mono lowering. +- Lambda Solved IR has no unresolved callable slot before direct LIR lowering. - Lambda Mono decisions contain no function type and no value-call node. - Lambda Mono decisions contain no unresolved lambda set. - Lambda Mono decisions contain no runtime tag discriminants or layout ids. - Checked compile-time stores contain only `ConstStore` data. -- LIR lowering receives only Lambda Solved lifted syntax plus Lambda Mono - decisions. +- LIR lowering receives only Lambda Solved lifted syntax plus the solved inline + plan and owns its logical Lambda Mono decisions. - ARC insertion receives LIR containing no RC statements. - ARC output passes the debug borrow certifier. - Backends receive only ARC-complete LIR. diff --git a/iter_fusion_design.md b/iter_fusion_design.md new file mode 100644 index 00000000000..0a5d3c558de --- /dev/null +++ b/iter_fusion_design.md @@ -0,0 +1,322 @@ +# Iterator Design Contract + +This contract governs the compiler-internal representation and optimization of +`Iter` and `Stream`. Their public source APIs are frozen. Bounded chains use +per-chain minted nominal types; recursive or over-cap chains use an explicit +forced-dynamic representation. Optimized modes additionally run the general +SpecConstr and wrapper-inline machinery before direct Lambda Solved-to-LIR +lowering. + +## As-Built Status (2026-07) + +The shipping iterator goal is complete. + +- The authoritative Rocci Bird `--opt=size --target=wasm32` cart is 35,175 + bytes with idiomatic iterators. +- The equivalent direct-list cart is 35,133 bytes. +- The iterator premium is 42 bytes. +- The iterator cart boots with the same `OK 191` result as the direct-list cart. +- The iterator chain and constant base list perform zero runtime heap + allocations on the cart gate. +- All 18 focused allocation cases pass across interpreter, dev, and Roc's WASM + backend. +- Runtime-recursive dev `concat` terminates, lowers, and executes correctly + through the forced-dynamic tier. The committed cart is 541,756 bytes with a + 600,000-byte regression ceiling. + +The remaining 24,520-byte gap to the 10,655-byte Rust cart is general runtime, +standard-library, platform, ARC, and code-generation cost. It is not iterator +representation overhead. + +## Public Contract + +The source representation remains: + +```roc +Iter(item) :: { + len_if_known : [Known(U64), Unknown], + step : () -> [One({ item, rest : Iter(item) }), Skip({ rest }), Done], +} + +Stream(item) :: { + len_if_known : [Known(U64), Unknown], + step! : () => [One({ item, rest : Stream(item) }), Skip({ rest }), Done], +} +``` + +There is no public iterator trait, chain type parameter, private source-visible +step tag, or runtime-tagged universal chain API. Adapters and custom sources +remain ordinary Roc functions. + +## Goals And Non-Goals + +The representation goal is that every statically bounded iterator or stream +chain can be carried by value without an iterator-attributable heap box, +including chains that escape a local expression, cross function boundaries, or +are selected by branches. + +Recursive construction whose adapter depth is a runtime value must still +compile through a finite type and callable universe. Such a chain may +materialize dynamic state. The compiler must prefer correct explicit dynamic +representation over unbounded specialization. + +Optimized generated code should approach the corresponding hand-written loop. +Non-optimizing backends must be correct and bounded; matching optimized +throughput there is not a goal. + +## Hard Invariants + +1. `Iter(item)` and `Stream(item)` keep their public APIs. +2. Purity and effect semantics are unchanged. The compiler does not turn pull + stepping into eager mutation. +3. No algebraic iterator rewrite may skip, duplicate, or reorder user + computation. +4. Materialization consumers such as List, Set, and Dict construction still run + exactly where written. +5. Element order and Stream effect order are preserved exactly. +6. User `is_eq` results never license compiler value substitution. +7. Backends receive ordinary LIR plus explicit ARC statements. They do not + receive iterator representation policy. + +## Representation Tiers + +Monotype `TypeDef` records: + +```zig +const IteratorRepresentation = enum(u8) { + none, + minted, + forced_dynamic, +}; +``` + +The representation and `iterator_depth` fields participate in type equality, +cross-store equality, and digests, and every type-store translation preserves +them. + +### Public + +`none` is the ordinary public nominal. It carries the recursive public backing +and no internal chain identity. + +### Minted + +`minted` is a statically bounded internal chain nominal. Its +`def.generated` digest includes adapter kind, item type, component types, and +callable evidence where required. Its `iterator_depth` records the chain depth +computed where the type is created. + +The backing rewrites public recursive `rest : Iter(item)` occurrences to the +minted self type. Additional nominal arguments record concrete components such +as the predecessor iterator and adapter captures. Each adapter layer therefore +embeds a different concrete predecessor by value; the layout graph does not see +one public nominal's recursive self edge. + +Minted step callables remain finite Lambda Solved callable sets. They become +ordinary generated callable tag-union values during direct LIR lowering. + +### Forced Dynamic + +`forced_dynamic` is the explicit finite fixed point for recursive and over-cap +construction. It is interned per item-type digest, retains the public source +declaration identity, and has a public-shaped backing whose recursive +`rest` occurrences point to the forced-dynamic self type. + +A forced-dynamic iterator is distinct from both the public nominal and every +minted nominal. When it meets either in Monotype or Lambda Solved unification, +the forced-dynamic root wins. Lambda Solved unifies item and backing types so +all reachable step implementations flow into the boundary, then marks the +completed backing's step callable as erased. Direct LIR lowering emits packed +erased callable values and indirect calls from that solved data. + +The dynamic tier does not imply one fixed heap-allocation count. The exact +layout can keep a finite erased callable payload by value, while genuinely +recursive public-shaped state may require materialization. The contract is a +finite, explicit representation and correct behavior, not a requirement to +manufacture a heap box. + +## Mint-Depth Bound + +`generatedIteratorType` in `src/postcheck/monotype/lower.zig` is the sole +minting producer. It uses: + +- maximum minted chain depth: 16; +- bounded structural walk budget: 64; +- source depth: 1; +- adapter depth: one plus the maximum component depth reachable by value. + +Minted children contribute their recorded depth. Forced-dynamic children +contribute the cap, so adapters above a dynamic boundary remain dynamic. +Records, tuples, tag payloads, named arguments, lists, and boxes propagate +contained value depth. Function types contribute no stored value depth, and +named backings are not traversed. + +When the next depth exceeds the cap, the producer returns the interned +forced-dynamic type. If the structural walk exhausts its own budget, it reports +the cap. The bound therefore limits the type universe at its creation point and +gives recursive specialization a finite fixed point. + +Lambda Solved never computes chain depth from transformed type structure. It +consumes the representation tier and recorded depth produced by Monotype. + +## Consumers And Specialization + +Consumers such as `next`, `fold`, `for`, and `collect` specialize against +the concrete internal nominal family. Source `for` becomes ordinary Monotype +loop, match, and call structure carrying the current iterator explicitly. + +Every build mode follows the same production route: + +```text +Monotype + -> Monotype Lifted + -> optional SpecConstr + -> Lambda Solved + -> solved inline plan + -> SolvedLirLower + -> LIR optimization and ARC +``` + +Dev and interpreter modes use `InlineMode.none`. Size and speed modes use +`InlineMode.wrappers`, which runs Monotype Lifted SpecConstr and solved wrapper +inline analysis. + +SpecConstr is a general call-pattern specialization pass, not a second iterator +representation. For exposed iterator and stream loops it can inline finite +callables, split known constructor state into leaves, simplify known matches, +and scalarize loop-carried state. Every reachable `continue` edge must supply +the required leaves, including transitions where adapter state changes. + +Minting removes recursive layout allocation before any backend sees the +program. SpecConstr exposes scalar loop shape in optimized modes. LLVM then +performs ordinary target optimization on the already-flat LIR. + +## Constant List Storage + +Compile-time finalization can turn an eligible constant list into an explicit +`static_data_candidate`. Direct LIR lowering emits its bytes into the data +segment. This is separate from minting and SpecConstr, but it is required for a +constant list's base allocation to disappear from the shipping cart. + +The eval allocation harness does not run this constant-hoisting path, so its +constant-list iterator case deliberately permits the one base-list allocation. +The static-library cart gate is authoritative for the zero-allocation constant +list claim. + +## Rejected Approaches + +These remain rejected. + +1. **A public iterator trait or chain type family.** This changes the frozen Roc + API. The compiler recovers concrete chain identity internally instead. +2. **Eager mutation modeled after Rust's `&mut self`.** This changes Roc + semantics and does not itself remove a recursive data-layout edge. +3. **One uniform internal layout for all `Iter(item)` values.** Layout + recursion is keyed on nominal identity. Varying only a backing under one + nominal does not move predecessor state into a different recursive component. +4. **Continuation flattening as the representation fix.** Iterator predecessor + recursion is stored data, not only recursion behind a continuation return. +5. **A missing callable variant treated as unreachable.** The dev recursive + `concat` investigation proved the missing variant executes at runtime. + Routing it to an impossible branch is unsound. +6. **Binary size, fingerprints, or differential values as allocation proxies.** + Allocation claims require allocation counters or static LIR shape evidence. +7. **A second iterator-only fusion representation.** The implemented general + SpecConstr pass already supplies optimized scalarization, and the measured + cart premium is only 42 bytes. More iterator-specific machinery requires a + new measured deficiency. + +## Acceptance Gates + +The durable focused gates are: + +- `src/eval/test/eval_iter_alloc_tests.zig`: 18 allocation-counted cases across + interpreter, dev, and WASM; +- `src/eval/test/lir_inline_test.zig`: bounded escaping chains stay flat, + recursive and over-cap chains use forced-dynamic erased callables, and + iterator loops expose the required scalar shape; +- `test/wasm/iter_list_hoist_static_lib_app.roc`: constant list plus iterator + chain has zero runtime allocations; +- `test/wasm/iter_for_static_lib_app.roc`: optimized iterator `for` drive + boots and stays under its cart ceiling; +- `test/wasm/iter_for_noiter_static_lib_app.roc`: direct-list size twin; +- `test/wasm/iter_recursive_concat_static_lib_app.roc`: dev recursive + `concat` returns `ok`, balances allocations/deallocations, and remains below + 600,000 bytes; +- Rocci Bird iterator and direct-list carts: equal `OK 191` behavior and the + measured 42-byte premium. + +Focused implementation work must run the smallest relevant subsets first. +Whole-tree CI is a final integration gate, not the inner edit/test loop. + +## ARC Size Pilot + +A scoped post-ARC scan of both Rocci Bird carts found no safe non-adjacent +retain/release pair to elide. In particular: + +- no retain had a matching same-local release within the next 16 straight-line + statements; +- allowing pure reference assignments between a retain and release did not + expose a same-value pair; +- the repeated superficially promising sequence retained a value produced by + `list_get_unsafe` and later released a different aggregate local. + +The last sequence is not an ownership transfer. Retaining a borrowed list +element cannot be canceled against releasing a containing or otherwise nearby +aggregate: its release does not necessarily remove the ownership unit added to +that element. Layout equality and a one-child deep-release plan are insufficient +proof of value identity. + +An exact struct-field move-out rule was also prototyped. Its proof required a +single-definition parent, the exact projected field and offset, a parent deep +release with that field as its sole RC child, equal atomicity, and only pure +unrelated reference reads between the operations. The carts had no such +non-adjacent occurrence; the focused fixture naturally emitted the retain and +parent release adjacent. Keeping extra solver data for a zero-hit rule would add +compiler complexity without improving either cart, so the prototype was +discarded. + +No ARC change was landed from this pilot. Further size work started from +measured whole-cart reachability and composition rather than broadening ARC +rules without an exact ownership proof. + +## Broader Cart-Size Audit + +The platform now declares `exports: ["start", "update"]` as its complete final +wasm ABI. Previously, 36 public object symbols became link roots. Making the +two real host entrypoints explicit removed 28 functions and 5 imports, reducing +both carts by 1,325 bytes without changing their 42-byte difference. + +Non-debug size output also strips the final `target_features` custom section. +That metadata was 392 bytes in both carts and did not affect executable code or +data. The two changes reduce each cart by 1,717 bytes in total: + +| cart | before broader audit | current | change | +|---|---:|---:|---:| +| iterator | 36,892 | 35,175 | -1,717 | +| direct list | 36,850 | 35,133 | -1,717 | + +A minimal application on the same platform measured about 3.1 KB after +metadata stripping, so the remaining gap is not an unavoidable 26 KB host +floor. In the named diagnostic cart, every function left after explicit export +rooting is referenced by an export, a direct call, or the function table; no +unreachable runtime or standard-library procedure remains to prune. + +The large `update` body and other Roc procedures were also tested against the +available structural controls. Disabling wrapper specialization increased the +iterator cart by 6,453 bytes and the direct-list cart by 4,434 bytes. Disabling +ARC procedure specialization produced byte-identical carts. Binaryen's existing +shrink-level-2 pipeline already performs duplicate and similar-function +elimination. Together with the exact ARC pilot's zero safe matches, these +results leave no measured structural candidate with both a correctness proof +and a cart-size benefit. + +## Current Conclusion + +Per-chain minting, explicit forced-dynamic representation, SpecConstr loop +scalarization, and constant-list static storage jointly deliver the iterator +goal. Explicit final exports and metadata stripping reduce general cart cost +without changing the current 42-byte iterator premium. Further work toward +Rust's total cart size belongs to separately justified ARC, runtime, +standard-library, platform, and code-generation efforts, not to a new iterator +representation or fusion campaign. diff --git a/projects/big/iterator-fusion.md b/projects/big/iterator-fusion.md new file mode 100644 index 00000000000..294d370e4b3 --- /dev/null +++ b/projects/big/iterator-fusion.md @@ -0,0 +1,276 @@ +# Iterator Minting: Bounded Iterators to Zero Allocation + +## Problem + +Roc's `Iter(item)`/`Stream(item)` allocates once per element for any adapter +chain. `Iter(item)` is a recursive nominal — its `step` returns `rest : +Iter(item)` — so `map`'s step captures the inner iterator as a same-nominal +field, forming a layout self-edge that the compiler boxes. Measured on the +committed allocation gate (`src/eval/test/eval_iter_alloc_tests.zig`, across +interpreter / dev / wasm): + +- `Iter.fold(Iter.map(Iter.exclusive_range(0, 5), |n| n + 1), 0, +)` — + **18 heap allocations**, expected 0 (`:44-46`). +- `Iter.fold(Iter.keep_if(Iter.exclusive_range(0, 6), |n| n > 2), 0, +)` — + **21** (`:49-51`). +- `Iter.fold(Iter.exclusive_range(0, 5), 0, +)` (base, no adapter) — **0** + (`:39-41`; Slice 1, commit `4c391bee43`, kept the base step inline). + +A `range` source's state is two integers, so none of the 18/21 are list buffers +— every one is the iterator machinery boxing a fresh successor per step. The same +box drives the `--opt=size` premium of an `.iter()` Rocci Bird build over the +direct-`List` loop. + +**Scope: every bounded iterator must reach zero allocations, in every usage** — +local (`for`/`fold`/`collect`) and escaping (returned across a boundary, stored, +consumed elsewhere) alike. The sole permitted allocation is a heap box for +genuinely runtime-unbounded nesting depth (`wrap` in a runtime-count loop; +recursive descent over runtime-shaped data), which Rust also boxes +(`Box`). + +**The mechanism: per-chain minting, as the single uniform internal +representation.** Compile each statically-known chain to a distinct internal +nominal per adapter — `MapIter(item, inner, f)`, `KeepIfIter(item, inner, p)`, +… — whose `inner` names the *concrete* predecessor nominal by value +(`MapIter{inner: RangeIter, f}`), never the recursive surface `Iter`. Because the +inner is a *different* nominal, the layout self-edge never forms, so the state is +flat and there is no box. The public `Iter(item)` surface stays frozen. This is +Rust's representation (`Map`) reached under a frozen API — and, like +Rust, the loop-collapse is left to LLVM (see Background). + +**Hard invariants (from `iter_fusion_design.md`, non-negotiable):** (1) the public +`Iter(item)`/`Stream(item)` API stays one concrete nominal — no trait/typeclass, +no chain type params on the surface — internals free; (2) purity — no eager +mutation; (3) no algebraic rewrites; (4) element order and effect traces preserved +exactly, and a user `is_eq` never licenses value substitution. Correctness is +absolute for both the representation and any optional pass — which is itself a +reason to prefer one uniform representation (minting) over two mechanisms plus a +decision boundary. + +## Background: the box, and the division of labor with LLVM + +The box is decided at one place. Layout runs a Tarjan SCC over the type graph +(`src/layout/store.zig:772-957`), and `shouldBoxRecursiveSlotEdge` (`:1046`) boxes +a slot edge exactly when `component_ids[parent] == component_ids[child]` (`:1061`) +— an SCC id that follows **nominal reference structure, never backing** — inserting +the box at `:1086`. Under the surface nominal, `map`'s `rest : Iter(item)` is the +same nominal as its parent, so they share a component → box. Minting makes the +inner a *distinct* nominal (`RangeIter`), so `component_ids` differ → no box. This +is why the fix must be a distinct nominal per adapter, not a smarter single layout +(see Approaches ruled out). + +**What Roc must do vs. what LLVM does.** `--opt=size` and `--opt=speed` compile +through LLVM (`src/cli/cli_args.zig:432` — size is "LLVM optimized for binary +size"), including the wasm32 target Rocci uses; `--opt=dev` and the interpreter do +not. + +- **The box is a Roc-IR-level `roc_alloc`, opaque to LLVM** — LLVM cannot remove + it. So *zero allocation is minting's job* (remove the box by flattening the + nominal), and it holds on **every** backend, including the interpreter/dev/wasm + backends the allocation gate runs on. +- **The loop-collapse is LLVM's job** — given the flat monomorphized nominals, + LLVM inlines each per-chain step into the consumer loop and dissolves the state + machine into the hand-written loop, exactly as it does for Rust's iterator + structs. On the non-optimizing backends the minted state machine is stepped + as-is: correct and allocation-free (iterator performance there is a non-goal). + +So minting alone greens the allocation gate on all backends, and minting + LLVM +gives the hand-written-loop machine code for the `--opt` builds that matter. + +## Evidence + +Symbols verified against the tree at HEAD. + +- **Allocation gate** (`eval_iter_alloc_tests.zig`): `range map fold` 18, `range + keep_if fold` 21, `range fold` 0. Confirmed by `zig build run-test-eval -- + --filter "iter alloc"`: 4 passed, 2 failed. +- **The box decision** (verified): SCC over the type graph + (`store.zig:772-957`); box iff `component_ids[parent] == component_ids[child]` + (`:1061`), keyed on nominal identity; `insertBox` at `:1086`. A distinct nominal + inner puts parent and child in different components → no box. +- **Consumers already specialize per nominal** (the make-or-break, resolved + YES): the specialization digest hashes nominal identity — module, `source_decl`, + `type_name` (`monotype/type.zig:942`) — so distinct nominals get distinct `fold` + specializations; and the where-bounded consumer shape already type-checks: + `collect : Iter(item) -> output where [output.from_iter : ...]` + (`Builtin.roc:2864`). The binding constraint was never consumer keying; it was + the layout SCC (`store.zig:1061`), which only a distinct nominal breaks. +- **The construction-site erasure** (`callResultMonoType`, `monotype/lower.zig:14316`): + a call's result type is `functionReturnType(mono_fn_ty)`, instantiated from the + callee's *declared* signature; a nominal's backing is a pure function of + `(declaration, args)` (`lowerNominalBackingType`, `:2126`). So `make()` returning + `Iter(U64)` carries the declaration backing, not the concrete chain — the exact + channel that must be built. +- **Blocker B** (`type_layout_resolver.zig:773-800`): the graph-builder layout + cache `nominalVarMatches` keys on module/ident/args (`:786-796`), never backing — + so two same-nominal chains with different capture sizes could collide. Minting + closes this by construction: distinct nominals have distinct `ident_idx`, so the + match fails (`:787`). The other layout cache is already backing-aware for + iter/stream (`solved_lir_lower.zig:6666-6677`). +- **`--opt=size` uses LLVM** (`cli_args.zig:432`); the CLI suite builds + `--target=wasm32 --opt=size` on the "LLVM size wasm backend". +- **Termination guard absent** (`reserveTemplateWithMonoFor`, `lower.zig:1487`): + dedups on a digest with no ancestor lineage, so a recursively-constructed chain + mints unbounded fresh templates — the widening cap's problem to solve. + +## Solution design + +Minting is the whole implementation; there is no separate "local" mechanism to +build. The pieces, ordered so each is a green checkpoint. The make-or-break is +already resolved against source (consumers specialize per nominal; the box breaks +for distinct nominals), so the first slice is an empirical de-risk of the one +unproven piece, the construction-site channel. + +1. **Slice 1 — channel spike (de-risk first).** Hard-code the construction-site → + backing channel for exactly `Iter.map` on a `RangeIter` receiver: mint and + carry `MapIter(U64, RangeIter, F)` through `callResultMonoType` + (`lower.zig:14316`) instead of the declaration-derived `Iter(U64)` backing. + Gate: `range map fold` (`eval_iter_alloc_tests.zig:44`, RED) flips to 0 across + interpreter/dev/wasm. **This one row greening is the whole approach made + physical.** If it cannot green by the channel alone, stop and escalate (see + Contingency). +2. **Slice 2 — surface↔internal bridge.** A one-directional coercion so a `MapIter` + value satisfies a surface `Iter(b)` position, since the two are distinct + non-unifying identities (`type.zig:942`) and `map : … -> Iter(b)` is frozen + (`Builtin.roc:2800`). Invariant 1 holds; the coercion is internal. Prototype on + the single Slice-1 case before touching consumers. +3. **Slice 3 — where-bounded consumer rewrite, `fold` first.** Move `fold` + (`Builtin.roc:2849`) from concrete `Iter(a)` to a where-bounded generic over the + internal-nominal family (the shape `collect` already proves, `:2864`). Confirm + base `range fold` (already GREEN, `:39-41`) still specializes — a + no-op-preserving checkpoint — then rewrite `next`/`for`/`collect`. +4. **Slice 4 — generalize the nominal family.** Extend the channel + minted family + to `keep_if`/`drop_if`/`take`/`concat`. Gate: `range keep_if fold` (`:49-51`) + flips to 0. +5. **Slice 5 — widening cap.** Add ancestor lineage to the deferred-template + request (`reserveTemplateWithMonoFor`, `lower.zig:1487`); when a minted chain's + backing shape matches an ancestor, collapse to the declaration backing, which + re-creates the `Iter` self-edge → `insertBox` (`store.zig:1086`) → the one + sanctioned box, and terminates as a fixed point. Ship a **hard depth backstop + first** so a mis-tuned cap degrades to the box (safe), never hangs; then tighten + the shape predicate. Gate: `wrap`/`leaves` box once per dynamic level and + terminate; a finite-but-deep static chain does not trip the cap. +6. **Slice 6 — escaping gate.** The escaping static gates (`lir_inline_test.zig`, + "iterator returned from a function" + passed-to-non-inlined + branch-chosen) + reach `box_box_count == 0` once the channel carries the concrete backing across + the `make`/`consume` boundary (the `body_uses_generated_evidence` seal/unify + carry, `lower.zig:1448-1475`, gated by `isGeneratedOpaqueEvidenceType` `:9700`, + enrolled for iter/stream). +7. **Slice 7 — Blocker-B guard.** Make a minted nominal's identity a function of + its full capture types, and add a differential test with two deliberately + different-capture chains sharing an adapter, asserting distinct layouts and + correct values (the silent-wrong-size-store the alloc gate reads *greener* on). +8. **Slice 8 — measure Rocci.** `.iter()` vs direct-`List` `--opt=size` premium on + CI benchmarks (no local benchmarking per standing guidance). + +**Optional fusion — only if measured to help, never as a required mechanism.** A +Roc-IR-level SpecConstr pass can eliminate a *locally-visible* chain before layout, +so no nominal is minted for it. Minting + LLVM already delivers both goals for +optimized builds, so fusion earns its keep only where it produces a **measured** +`--opt=size` or build-time win over always-mint-then-LLVM — plausible only because +LLVM inlines conservatively under `-Os` and may not fully collapse a deep minted +chain. The substrate exists (`spec_constr.zig`: known-callable inlining, known-tag +collapse, loop-state leaf-splitting) but does not fire on iterators today (blocked +by a capture-identity panic, `solved_lir_lower.zig:2313`, and the recursive-nominal +successor). Do **not** build it on principle; build it only against a Rocci size +number that minting-plus-LLVM left on the table. + +## What success looks like + +- The `eval_iter_alloc_tests` adapter rows (`range map fold`, `range keep_if fold`) + read 0 allocations on interpreter, dev, and wasm — the gate that reads 18/21 + today. Minting delivers this on every backend, LLVM not required. +- The escaping static gates in `lir_inline_test.zig` read `box_box_count == 0`; + only genuinely-unbounded-depth nesting keeps a box (`wrap`/`leaves`), where Rust + also boxes. +- Rocci Bird: the `.iter()` build's `--opt=size` premium over the direct-`List` + build is approximately zero — minting removes the box (Roc level), LLVM collapses + the flat struct to the loop. +- The differential harness stays green: minted output equals naive-unfused output + on values and effect traces, including the different-capture Blocker-B case. + +## How to evaluate the result + +### Correctness ideal + +The differential harness (`expectSameObservationsAcrossInlineModes`, +`src/eval/test/lir_inline_test.zig`) runs every pipeline both minted and +naive-unfused and requires identical values, crash-versus-no-crash, and — for +`Stream` — the full ordered effect trace against a mock host. Element order is +preserved; materialization points (Set/Dict/List construction) always execute, so +deduplication and ordering effects happen where written. No transformation may +claim a round-trip identity, and a user `is_eq` never licenses value substitution. +A list held by a live iterator must not be mutated in place: the committed guard +(`eval_iter_alloc_tests.zig`, "list held by a live iterator is not mutated in +place") asserts the pre-mutation elements are observed — inverted for allocation, +since an in-place-mutation miscompile both corrupts the view and *lowers* the alloc +count, so a green alloc number is necessary but not sufficient. + +### Performance ideal + +Zero heap allocation on every bounded chain, every usage, every backend — the box +confined to genuinely runtime-unbounded nesting depth, where Rust also boxes. On +`--opt` builds LLVM dissolves the flat minted state machine into the hand-written +loop (Rust's own outcome); the residual versus a hand-written mutable loop is a +constant-factor 3-way `One`/`Skip`/`Done` dispatch, never asymptotic. Measure with +the `eval_iter_alloc_tests` cross-backend counts, a reachable-proc `box_box`-count +shape helper, and the Rocci Bird `--opt=size` premium (CI benchmarks). + +## Contingency — if the Slice-1 channel spike is NO + +If `range map fold` cannot be greened by the channel alone, escaping (and local) +type-changing `map` cannot be zero-alloc under the frozen surface API, and the +owner faces a constraint decision: **Option A** — relax Invariant 1 (expose the +chain type-family at the surface, Rust's `impl Iterator`; delivers it with zero +technical risk, at the cost of the simple monomorphic public type); or **Option B** +— accept the box for adapter chains (the current behavior; the adapter rows stay +RED and Rocci keeps a premium, but base/local-fused/escaping-endomorphic stay +correct). Relaxing Invariant 2 (eager mutation) does not help — the box is stored +recursive data, not a stepping discipline. + +## Tests to add + +- **Adapter allocation rows to green:** `range map fold` (18→0, Slice 1) and + `range keep_if fold` (21→0, Slice 4) in `eval_iter_alloc_tests.zig`, cross-backend. +- **Escaping gate:** the "iterator returned from a function" + passed-to-non-inlined + + branch-chosen cases in `lir_inline_test.zig` read `box_box_count == 0` (Slice 6). +- **Blocker-B guard:** two different-capture chains sharing an adapter → distinct + layouts and correct values (Slice 7). +- **Widening cap:** `wrap`/`leaves` box once per dynamic level and terminate; a + finite-but-deep static chain does not trip the cap (Slice 5). +- **No-op consumer-rewrite checkpoint:** base `range fold` still green after `fold` + becomes where-bounded (Slice 3). +- **Rocci Bird size probe:** the `.iter()` vs direct-`List` `--opt=size` premium as + a tracked number, target ≈ 0. + +## Approaches ruled out + +Source-verified dead ends, recorded so they are not re-explored; full "why" in +`iter_fusion_design.md` "Rejected Approaches." + +- **A trait/typeclass `Iterator`, or chain type params on the public type** — + violates the frozen-API invariant. It is Rust's surface design; we recover its + effect internally via minting instead. +- **Eager mutation** — violates purity, and is orthogonal to the box. +- **One uniform layout for `Iter(item)`** — the one-nominal, vary-only-the-backing + form and the flat-max-union / coinductive-record forms all die at the same line: + the layout self-edge keys on nominal identity (`store.zig:1061`), so a same-nominal + inner always boxes regardless of backing. Only a distinct nominal per adapter + changes which SCC the inner lands in. (flat-max-union additionally: an + un-authorable flat `map` stage, plus Blockers B and C.) +- **Porting LSS's continuation flattening to unbox `map`** — a category error: + `map`'s box is stored recursive *data* (the captured inner iterator, LSS's own + `Cons`), which LSS also boxes. +- **Any proxy for the allocation goal** (a passing differential test, matching + Rocci size, an identical draw fingerprint) — each has read green while iterators + still allocated; only the runtime allocation count is acceptance. + +## Related projects + +- [Immutable Specialization Identity](immutable-specialization-identity.md) — the + monotype specialization surface (`monotype/lower.zig`, `specialize.zig`) that the + minting channel and per-chain consumer specialization extend; keying stability + there de-risks minting directly. +- Root design doc (authoritative, outside `projects/`): `iter_fusion_design.md` — + the contract (goals, invariants, the per-chain-minting representation, the + Roc/LLVM division of labor, rejected approaches, and acceptance). diff --git a/report.md b/report.md new file mode 100644 index 00000000000..8c9916e5284 --- /dev/null +++ b/report.md @@ -0,0 +1,1905 @@ +# Roc WASM-4, Rocci Bird, and Optimized Callable-State Lowering Report + +## Current State At This Checkpoint + +This report was written after stopping implementation on request. + +Current branch: + +```text +wasm-changes +``` + +Most recent implementation checkpoint before this report: + +```text +ac8c4bda0e Fix demanded loop entry state shape +``` + +The tracked working tree was clean before adding this file. `plan.md` is +intentionally ignored locally and was not committed with this report. + +The current active compiler regression is: + +```sh +zig build run-test-zig-lir-inline -- --test-filter "imported iterator producer keeps finite step callables" +``` + +Immediately before this report, that regression no longer failed at loop entry +state selection. It now fails later with: + +```text +postcheck invariant violated: sparse private callable was missing a demanded capture +``` + +That is the correct next invariant to investigate. It means optimized lowering +has reached a private callable call under result demand, has derived that the +callable body needs a particular capture, and then found that the sparse private +callable state does not carry that capture. The intended fix is not to +materialize the callable, not to infer a missing capture at the call site, and +not to force a source-specific inline path. The intended fix is an explicit +producer-owned fact: a demanded callable capture can be supplied by active loop +state. + +## Executive Summary + +The original task was narrow: make a current local build of Rocci Bird work, +then build it with size optimization and understand why the resulting WASM-4 +module was much larger than an older compiler output. That exposed several +layers of compiler and platform work. + +The platform side showed that Roc and the WASM-4 host were wasting space by +emitting large active zero-filled data segments for memory that WASM-4 already +requires to be zeroed at startup. The long-term design there is that the +platform explicitly declares the memory import contract, including whether the +imported memory is zero-filled, while the Roc compiler remains target-generic +and only consumes that explicit contract. The compiler should not know what +WASM-4 is, and the WASM-4 platform should not know about Rocci Bird. The memory +contract should be general enough for any WASM platform with imported memory. + +The binary-size investigation showed that Binaryen's `wasm-opt` is the normal +tooling used by small WASM-4 projects to get tight output. Roc cannot search +the user's `PATH`, so the long-term design is to bundle a library integration +for Binaryen in the Roc toolchain, alongside the already-bundled LLVM/lld +tooling, and call it as a library in optimized WASM builds. A separate +roc-bootstrap change was opened and merged to make Binaryen available. A later +Roc build was verified against the published bootstrap artifact for both glibc +and musl, and the ReleaseFast `roc` binary size increase for the custom +Binaryen integration was measured at roughly the expected order of magnitude +for this feature. + +The application side showed that Rocci Bird could be made to run again, but +that the size gap against a Rust port remained large. A Rust port was built and +optimized with Rust's normal release settings plus Binaryen. That gave a +baseline for what a small WASM-4 game can look like when iterators and state +machines lower well. Rocci Bird in Roc improved substantially, but it still +carried more code than the Rust version. The remaining gap was no longer +explained by obvious host exports, debug symbols, or the original giant zero +segment. The largest design issue moved into Roc's optimized lowering of +iterator/callable state. + +A number of smaller compiler and app improvements were made along the way: +snake_case cleanup in Rocci Bird, `Flags` as an opaque-style record-backed U32 +API instead of a list of flag values, use of bulk memory ops in WASM codegen, +host wrapper export cleanup, builtin additions such as `minus_saturated`, and +replacement of local Rocci Bird helper functions with builtins where +appropriate. Some of those changes are useful independent wins, but they do +not close the remaining Rust-size gap. + +The current central compiler project is optimized callable-state lowering. The +long-term ideal is Rust-like generated code for `Iter`, `Stream`, and similar +callable-state values, without adopting Rust's public type-system design. +Rust gets tight iterators by making each adapter chain a distinct +monomorphized static type. Roc must keep `Iter(item)` and `Stream(item)` as +ordinary concrete public Roc types whose `step` or `step!` fields are ordinary +Roc lambdas. The optimizer should use existing lambda-set data, captures, +known values, exact consumer demand, and loop fixed-point demand to +defunctionalize reachable callable/capture graphs into private cursor state in +`--opt=size` and `--opt=speed` builds. + +The most important lesson so far is that this work must be producer-driven. +Every optimization must consume explicit data produced by an earlier compiler +stage or by an earlier step of optimized lowering. Late cleanup passes, +source-shape rewrites, "just inline it", recursive expansion, materialization +as recovery, and call-site guessing all lead to fragile hacks. The correct +design is to represent the needed compiler facts directly: + +- exact demand on values and continuations +- known producer structure +- sparse private state by checked child identity +- finite callable alternatives with per-alternative capture demand +- loop-demand graph nodes for recursion +- public-boundary demand when ordinary public values are required +- loop-supplied callable captures as explicit private-state data + +The current implementation has already had several failed partial attempts +deleted. The most recent verified change fixed one concrete invariant: loop +entry state identity and loop entry value splitting now come from the same +demanded private representation. That moved the focused regression to the next +expected invariant: missing demanded callable capture. The next real work +should implement loop-supplied callable captures explicitly and prove it with a +focused regression before broadening back to Rocci Bird. + +## Project Goals + +### User-Visible Goal + +The visible goal is to build Rocci Bird locally with the current Roc compiler, +using size optimization, and get a valid WASM-4 game binary whose size is in +the same broad range as comparable hand-tuned WASM-4 projects. + +That includes: + +- a normal clone of the Rocci Bird repository +- a current local Roc compiler build +- a WASM-4 platform build that follows WASM-4's memory rules +- a `--opt=size` or equivalent size-focused Roc build +- Binaryen optimization for optimized WASM outputs +- a working local WASM-4 server so the game can be manually tested +- a clear binary-size comparison against the old Rocci Bird WASM and against a + Rust port + +### Platform Goal + +The WASM-4 platform should follow WASM-4's rules in a general way. It should +not contain any Rocci Bird-specific choices. Rocci Bird should not pass special +memory settings to its platform. The default WASM-4 platform configuration +should be correct for normal WASM-4 games. + +The platform should express target facts that are true for the platform: + +- whether linear memory is imported or defined by the module +- when memory is imported, which host module/name pair is used +- whether startup memory is known to be zero-filled +- what memory size limits or initial pages are required + +The platform should not encode compiler-specific cleanup hacks, and the Roc +compiler should not know what WASM-4 is. + +### Compiler Goal + +The Roc compiler should stay generic. It should not have WASM-4-specific +branches, Rocci Bird-specific branches, source-name recognition, or backend +heuristics for one game. + +Compiler work should be based on explicit facts: + +- platform memory contract facts +- checked and monomorphized value facts +- exact demand facts +- explicit layout/runtime encoding decisions +- explicit LIR statements, including ARC statements + +The compiler should not recover information late by scanning source-like +syntax, emitted LIR, symbol names, disassembly, or target bytes. + +### Optimized Iterator/Callable-State Goal + +The long-term ideal for Roc iterators and streams is: + +- `Iter(item)` remains an ordinary public Roc type. +- `Stream(item)` remains an ordinary public Roc type. +- The public step result stays `One`, `Skip`, or `Done`. +- There is no public or private `Append` step variant. +- Adapters such as `.map`, `.append`, `.filter`, `.concat`, ranges, and custom + iterators remain ordinary Roc functions. +- Step fields remain ordinary Roc lambdas. +- The optimizer uses lambda-set data, captures, known values, and demand to + lower consuming hot paths to private cursor state. +- In optimized builds, iterator and stream pipelines should lower like Rust's + monomorphized iterator state machines in the cases where Roc has enough + finite callable data. +- In non-optimized builds, ordinary public-value lowering should remain the + path. The optimized demand graph machinery should not be constructed. + +The target generated shape is: + +- no heap allocation for adapter wrappers in consuming hot paths +- direct stepping where possible +- sparse carried state containing only demanded children +- finite callable alternatives represented directly, not erased to runtime + callable wrappers +- recursive loop-carried demand represented by graph nodes, not by infinitely + expanding structural trees +- ordinary scope-closed LIR before ARC and backend codegen + +### Hoisting and Static Data Goal + +A related but separate compiler goal was identified during the Rocci Bird +investigation: every eligible top-level value, and hoisted top-level-equivalent +value, should be evaluated at compile time. Values whose final results are +reachable should be emitted into the static section of the binary. This should +include top-level sprites and derived sprites that depend only on compile-time +known values. + +The ideal behavior is: + +- all modules are considered, not only the root module +- top-level values are evaluated even if unreachable, so compile-time crashes, + `dbg`, and `expect` behavior can be reported correctly +- only reachable evaluated values need to be stored in the final binary +- structural type shape, nominal/opaque wrappers, and similar source-level + categories should not decide hoisting eligibility +- expressions with unresolved or erroneous subexpressions are not hoisted only + for those erroneous expressions, not for the whole program +- effectful functions disqualify the containing expression from compile-time + evaluation +- `crash`, `dbg`, and `expect` are not reasons to avoid compile-time + evaluation; if they are reached during compile-time evaluation, their + observable behavior should happen at compile time + +This work matters for Rocci Bird because sprite sheets and sprite records +should not be recreated in update bodies or allocated on the heap at runtime +when they are compile-time constants. + +## Non-Negotiable Constraints + +The constraints below have become more important than the specific binary-size +number. + +### Long-Term Ideal Only + +The user repeatedly clarified that the project should always aim directly at +the long-term ideal. Temporary shortcuts, fallbacks, staged hacks, and "good +enough for now" designs are not acceptable. + +That means: + +- do not add a source-specific optimization because Rocci Bird happens to need + it +- do not add an iterator-specific special case if the correct design is a + general callable-state optimization +- do not add a wasm-specific branch if the correct design is an explicit + platform memory contract +- do not add a cleanup pass if the correct design is to produce the right data + earlier +- do not keep old and new paths alive unless both are permanent public paths + described by the design + +### No Workarounds, Fallbacks, Or Heuristics In Compiler Stages + +The local project instructions explicitly forbid workarounds, fallbacks, and +heuristics in compiler stages outside parsing/error reporting. + +For this work, that means: + +- no hardcoded local ids +- no hardcoded symbols or names +- no builtin-name recognition to make one pipeline lower well +- no late LIR cleanup that guesses what an earlier stage meant +- no materialization fallback for sparse private state +- no recursive direct-call expansion as a substitute for loop-demand graph + nodes +- no browser-based bug hunting as primary proof +- no disassembly-derived recognition rules + +### Producer-Owned Facts + +Every consumer must read explicit data from the producer that owns the fact. +Examples: + +- the platform owns the imported-memory contract +- checking/solving owns lambda-set and inline-wrapper facts +- optimized lowering owns demand graphs and sparse private state +- ARC owns explicit reference-count statements +- backends lower what LIR and ARC tell them + +If a later consumer finds itself trying to recover missing information, the +producer is incomplete. + +### Public Shape Is Not An Optimization Knob + +Roc public APIs should not be bent to make the optimizer easier. This came up +several times: + +- `Iter` must not gain a public `Append` step variant. +- `Iter` must not become a Rust-style trait/interface. +- `Iter` must not expose adapter-chain identity in the public type. +- `Flags` in Rocci Bird should have a clean Roc API, not a shape selected only + for codegen. +- Rocci Bird should use normal platform defaults, not special memory config. + +Optimized lowering can use private representations, but the public Roc program +must remain ordinary Roc. + +## WASM-4 Memory Work + +### Original Observation + +Rocci Bird's current optimized WASM was much larger than the older binary. One +obvious culprit was a giant active zero-filled data segment. That meant the +module was paying many kilobytes to explicitly initialize memory bytes to zero. + +For WASM-4 this looked wrong because WASM linear memory starts zeroed, and +WASM-4 supplies/imports memory according to its own host contract. If the host +guarantees zero-filled startup memory, encoding a huge all-zero data segment is +wasteful. + +### Design Question + +The important design question was where the knowledge belongs: + +- Is zero-filled memory a WASM-4 host fact? +- Is it a Roc compiler fact? +- Should the compiler post-process all-zero segments? +- Should the platform say something explicit? +- Does WASM itself define zero initialization? + +The conclusion was that the platform should state the memory contract +explicitly, and the compiler should consume it generically. + +### Import Memory Is A WASM Concept + +Importing linear memory is an ordinary WebAssembly feature. A module can define +its own memory or import it from the host. With the common single-memory model, +there is one linear memory for the module. Multi-memory exists in real life, +but it is still uncommon in practice and not the typical shape for WASM-4. + +The design can still leave room for multiple memories later, but the current +platform work should not over-engineer around a rare case. The important point +is that "import memory" is a general WASM concept, not a WASM-4-only idea. + +### Zeroed Versus Uninitialized + +The user questioned why zero-filled memory had to be a fact at all. The +specific answer is that static storage whose initial value is zero relies on +zero initialization. If the compiler assumes zero-initialized memory but the +host actually provides arbitrary bytes, then static zero values can be wrong. + +Concrete examples include: + +- a static integer value with initial value `0` +- a static pointer or nullable field represented as zero +- static aggregate fields omitted from active data because they are zero +- runtime state that expects BSS-like memory semantics + +If the memory is known zeroed, the compiler can omit active data segments that +only write zeros. If memory is not known zeroed, omitting those segments would +be a correctness bug. + +This is why the "zeroed" fact should exist. It is not an optimization for +Rocci Bird; it is a correctness contract between the module and the host. + +### Chosen Shape + +The design moved toward an enum-like memory import contract instead of a +separate boolean: + +```text +import_memory = Zeroed | Uninitialized | No +``` + +Conceptually: + +- `No`: the module defines memory itself +- `Zeroed`: memory is imported and startup bytes are known zero-filled +- `Uninitialized`: memory is imported but the compiler cannot assume bytes are + zero-filled + +The platform can then say what the host provides. The compiler can use that +contract without knowing what WASM-4 is. + +### Removing The Memset + +There had been a startup `memset` or equivalent zeroing path. Once the memory +contract says startup memory is zero-filled, that memset is unnecessary. It +only duplicates work and can add code size. The user asked to remove it, and +that is the right direction. + +If a platform chooses `Uninitialized`, then it must either explicitly initialize +what it needs or accept that static zero assumptions are invalid. The compiler +should not silently guess. + +### Post-Link Cleanup Concern + +There was concern that doing both up-front memory configuration and a post-link +cleanup pass for all-zero data segments sounded like layered hacks. + +The design distinction is: + +- the platform contract is the source of truth +- the compiler/linker path may still produce active zero segments because LLVM + and wasm-ld do not necessarily know Roc's higher-level memory contract in + the exact way we need +- a generic post-link rewrite can remove active all-zero data segments only + when the explicit memory contract says startup memory is zero-filled + +That cleanup is still less ideal than avoiding the segments at the producer, +but it is generic and contract-driven. Later, Binaryen becomes the more normal +place to perform this kind of WASM cleanup and optimization. The important +line is that cleanup cannot be based on "this is WASM-4" or "this is Rocci +Bird"; it must be based on the explicit zero-filled startup memory contract. + +## Binaryen Integration + +### Why Binaryen Came Up + +Looking at other WASM-4 projects showed that small WASM binaries commonly use +Binaryen's `wasm-opt` to shrink and clean up output. Rust projects and WASM-4 +examples are not hand-writing custom post-link zero-segment rewrites. They use +normal WASM optimization tooling. + +This explained part of why the old or Rust WASM outputs were smaller. LLVM/lld +alone are not the whole optimized WASM toolchain story. + +### No PATH Lookup + +The user was explicit: Roc will not search the user's `PATH` for tools. That +rules out shelling out to a user-installed `wasm-opt`. The only acceptable +design is for Roc to bundle the relevant dependency and call it in a controlled +way. + +That means: + +- do not invoke `wasm-opt` from `PATH` +- do not make builds depend on the user's machine having Binaryen installed +- do not add ad hoc external process behavior +- integrate the Binaryen library version through the Roc bootstrap/toolchain + path + +### Library Integration + +The direction chosen was to add Binaryen to `roc-bootstrap` alongside LLVM. +This keeps the dependency controlled and reproducible. The custom build avoids +parts that Roc does not need and keeps the integration focused on the Binaryen +library functionality needed by optimized WASM builds. + +There was discussion about C++ standard library dependencies and musl. The key +point is that Roc already has a serious native dependency story because it +builds and bundles LLVM/lld. Binaryen is another C++ project, but that is not a +new category of problem. The right comparison is not "does Binaryen have a C++ +build"; it is "can our controlled bootstrap build produce a working Roc binary +for the targets we ship." + +The custom Binaryen build was verified as the relevant artifact, not upstream +`libbinaryen.a` in isolation. The custom build was the thing that mattered. + +### Size Cost + +The useful measurement was a ReleaseFast `roc` binary with and without the +custom Binaryen integration. The expected size increase was roughly around +10 MB for the final optimized compiler binary. That is significant but not out +of line for adding a WASM optimizer library to a compiler that already bundles +LLVM/lld. + +The user was clear that measuring a non-working binary is irrelevant. The +measurement only matters if the resulting compiler builds and works. The custom +build did work in the verification path. + +### Binaryen Optimization Levels + +The relevant Binaryen optimization modes discussed were: + +- `-O4`: aggressive optimization for performance and code simplification +- `-Os`: size optimization +- `-Oz`: more aggressive size optimization + +For Roc's `--opt=size` or `--opt=small` WASM outputs, the expected Binaryen +setting is the size-focused one, typically `-Oz`. ReleaseSmall platform builds +and Roc size builds should use the size-focused Binaryen pass selection. + +### Producer Sections + +`--strip-producers` removes the custom "producers" metadata section from a +WASM module. That metadata records toolchain information such as languages and +tool versions. It is useful for diagnostics but not needed to run a WASM-4 +game. Stripping it saves bytes and avoids exposing build metadata. + +## Rocci Bird Application Work + +### Repository And Build + +The Rocci Bird repository was cloned normally, not shallowly, into: + +```text +~/code/roc-wasm4 +``` + +The compiler work happened in the Roc worktree: + +```text +/home/rtfeldman/code/worktrees/roc/vivid-canyon/roc +``` + +Rocci Bird was built against the current local compiler after building the +compiler as needed. + +### Regression Capture + +Early on, the user asked to add a regression test for the current compiler +instead of minimizing the whole Rocci Bird setup. The goal was to capture the +exact failing code and platform situation so the bug could be investigated +later. That established an important pattern: before minimizing or refactoring, +capture the real failure in a reproducible test. + +### Snake Case + +The Rocci Bird `.roc` file was converted from camelCase to snake_case and run +through `roc fmt`. The generated temporary name `rocci-bird-snake` was just an +intermediate artifact from that conversion, not a design concept. + +### Game Behavior Bug + +An optimized Rocci Bird build initially showed an immediate "Game Over" after +clicking. The symptom was that the bird immediately entered the hit-a-pipe +animation and transitioned to game over. + +The important diagnosis constraint was that this looked like an application +logic or compiler optimization issue around collision detection, not an exotic +WASM-4 host bug. The user specifically pointed to the collision calculation. + +A dev build on another port worked while the optimized build did not, which +strongly suggested an optimization/codegen issue rather than app logic alone. +Disassembly comparison was used to track that down. A later compiler fix made +the optimized build work again. + +### Bulk Memory Ops + +One concrete compiler-side change that survived was to use WASM bulk memory +operations where appropriate, rather than byte-by-byte aggregate copying in +generated code. This was kept because it is the right long-term codegen shape, +not a Rocci Bird-specific hack. + +The user later clarified not to force this everywhere beyond freestanding or +where LLVM would choose it. The direction became to keep the logic outside +vendored wrapper code when possible, so updating vendored code would not lose +Roc-specific build intent. + +### Host Wrapper Exports + +The disassembly showed unused host wrappers being exported. The user correctly +challenged this: host-provided functions that are merely exposed to the Roc app +do not need to be exported from the WASM module. Only functions that WASM-4 +will call need to be exports. Hosted functions should be eligible for ordinary +section garbage collection if the app does not use them. + +This is an example of a fast size win that is also semantically cleaner. + +### Flags Refactor + +Rocci Bird originally represented draw flags as a list. The user asked to make +flags a record-backed opaque-style value over a `U32`, with APIs such as: + +```roc +Flags.default().flip_x().flip_y() +``` + +The correct Roc syntax constraints were clarified during implementation: + +- use `::`, not `:=` +- backing shape should be `{ bits : U32 }`, not a bare `U32` +- `@Flags` is not valid Roc syntax in the current language +- destructuring should use `|{ bits }|` +- use `bits.bitwise_or(2)` style calls + +This change reduced overhead and made the app code cleaner. + +### Builtins + +The user asked to add builtins for helpers Rocci Bird had locally: + +- `minus_saturated` on number types, corresponding to the existing saturating + subtraction behavior such as `sub_saturating_u64` +- `List.append_if_ok`, replacing the local Rocci Bird `append_if_ok` + +The goal was to remove app-specific helper code and make the standard library +or builtins provide the general operation. + +### Numeric Formatting + +Numeric formatting was investigated as one of the code-size culprits. There was +discussion around manually fixing one case in the app while tracking the +compiler issue separately. A GitHub issue was opened for the broader compiler +bug when the observed behavior looked like a compiler optimization problem. + +### Collision Points And `.iter()` + +Rocci Bird's collision-point code became a central microbenchmark. The version +without `.iter()` was much smaller than the version with `.iter()`, even after +other fixes. Removing `.iter()` saved several kilobytes in optimized output. + +The user wanted the list-style version restored because long-term hoisting and +iterator lowering should make it optimize well. That is the right design +stance: the app should not have to inline a hand-written boolean chain or +manually avoid iterator APIs to get reasonable code. + +The current compiler work is largely about making that expectation true. + +## Rust Port And Size Comparison + +### Purpose Of The Rust Port + +The Rust port was created to answer a simple question: how small does this kind +of WASM-4 game get with another mature compiler stack and normal WASM +optimization? + +The Rust version was built using Rust's optimizations and then Binaryen. It was +served locally on a different port from the Roc version so both could be tested +side by side. + +### What Rust Shows + +Rust's iterator optimization gives a useful target shape: + +- iterator adapter chains are static types +- each adapter's state is stored directly +- `next` is usually inlined into a tight loop +- no heap allocation is needed for adapter wrappers +- closure captures become ordinary fields in the iterator state +- the optimizer can eliminate intermediate wrappers aggressively + +Roc should not copy Rust's public typing model. Rust's `Iterator` is a trait +and adapter chains are represented in the type system. Roc's `Iter(item)` must +remain one concrete public type. + +The relevant lesson is not "make Roc's type system like Rust." The lesson is +"make Roc's optimized private lowering reach the same generated-code shape." + +### Why Roc Is Bigger Today + +After the obvious WASM and platform issues were fixed, the remaining size gap +appeared to come from: + +- iterator/callable adapter state not fully erased into private cursor state +- public wrapper records and callables surviving too long +- sparse demanded state missing some facts and falling back to less optimal + paths or crashing on invariants +- allocation and refcounting around list/iterator state +- helper bodies and generic iterator support being retained +- closure/callable capture handling not yet equivalent to Rust's direct fields + +The current work in `spec_constr.zig` is trying to fix that at the correct +level: optimized post-check lowering of callable-state values under exact +demand. + +## Hoisting And Static Data + +### Original Sprite Question + +Rocci Bird had comments saying sprite data regenerated every frame due to a +compiler bug. The user questioned whether that was still true. The expectation +was that top-level sprite sheets and derived sprites should be compile-time +constants in static data. + +The investigation showed that the compiler was far from the ideal: + +- not every module was being handled +- top-level-equivalent expressions were not consistently evaluated +- static data emission was incomplete +- some constants were still being reconstructed or stored through runtime paths + +### Correct Hoisting Design + +The user clarified the desired design: + +- every eligible top-level value in every module should be evaluated at compile + time +- top-level-equivalent hoisted values should also be evaluated at compile time +- `crash`, `dbg`, and `expect` must run at compile time if reached +- effectful function calls disqualify the containing expression +- unreachable top-level values should still be evaluated for diagnostics and + observable compile-time behavior +- only reachable evaluated values need to be emitted into the binary's static + section + +This avoids needing later dead-code elimination for static constants while +still ensuring compile-time behavior is checked. + +### Effectfulness Bug + +A soundness issue was investigated around effect propagation and static +dispatch. The key shape was a function with an ability member that could be +effectful depending on the resolved implementation. + +The diagnosis was that effectfulness could not be treated as a purely syntactic +property without considering resolved dispatch/type information. The compiler +needs to propagate effectfulness through the actual resolved call graph, +including ability dispatch. If an ability implementation is effectful, generic +code that calls that ability method must be considered effectful for that +instantiation. + +The user and agent discussed efficient invalidation/propagation. Union-find was +considered but not identified as the core solution. The more relevant shape is +a dependency graph between functions/ability dispatch slots/effect summaries, +with invalidation when a callee's effect summary changes. This should be +efficient enough for `roc check` because checking already walks expression +nodes. + +### Hoisting Root Selection + +The original hoisting code had multiple bad ideas: + +- separate competing ideas of root eligibility +- multiple walks +- not hoisting crashes +- treating standalone leaves as not worth roots +- treating observable `dbg`/`expect`/`crash` behavior as a reason not to hoist +- confusing effectful functions with observable compile-time behavior + +The long-term design should have a single source of truth and a single pass +where possible. During checking, the compiler already traverses expressions +and computes effect information. That traversal can also compute compile-time +evaluation candidates and parent/child relationships. Later, after effect +summaries are known, candidates that contain effectful calls can be rejected. + +The important distinction is: + +- an expression with unresolved/erroneous subexpressions should not be hoisted + if those errors affect that expression +- errors elsewhere in the program must not globally disable hoisting + +The compiler's broader design goal is to keep doing as much work as possible +in the presence of unrelated errors. + +### Static Storage And Reachability + +The ideal end state is: + +- evaluate all eligible top-level/comptime expressions for all modules +- store all evaluated results in `ConstStore` +- emit only reachable static values into the final binary +- ensure reachable static values include their full data in static sections +- ensure derived static values share underlying byte data where appropriate +- ensure runtime code does not rebuild static records/lists every frame + +For Rocci Bird specifically: + +- sprite sheets should be static byte lists +- sprite records should be static records +- `Sprite.sub_or_crash(...)` results that depend only on static inputs should + be evaluated at compile time +- those derived sprites should point to shared static byte data instead of + copying it or rebuilding it + +### Current Status Of Hoisting Work + +There was a branch with hoisting fixes that was merged into this branch during +the broader work. After follow-up fixes, the optimized Rocci Bird build looked +good again in the browser. However, the iterator/callable-state work remains +separate and incomplete. + +The hoisting design and plan were rewritten several times as the user pushed +for the real long-term ideal. `plan.md` is now local/ignored. `design.md` +contains the long-term design direction that should be treated as the durable +reference. + +## Iterator Representation Experiments + +### The `Append` Variant Experiment + +At one point, an experiment changed the iterator step result shape by adding an +`Append` variant. The idea was that `.append()` could become faster if the step +result directly represented "before iterator plus appended item" or similar +state. + +This was rejected. The user made it explicit that every `Iter` should have the +same public three-variant shape: + +```roc +One(...) +Skip(...) +Done +``` + +There should be no public or private `Append` step variant in Roc source. + +The deeper lesson was that changing public API shape to make the optimizer +easier is the wrong direction. `Iter` is a builtin, so the compiler may give +it a better private lowering, but the public-facing API should remain pure Roc +functions and ordinary lambdas. + +### Rust Comparison + +Rust does not have this problem because it does not erase every iterator chain +to one concrete runtime type. The adapter chain is part of the static type. +That allows inlining and scalar replacement to see concrete fields and produce +tight loops. + +Roc intentionally does erase adapter identity from the public type: + +```roc +Iter(item) +``` + +The way Roc can recover the optimized shape is through lambda sets. The step +field is an ordinary lambda. Lambda-set solving already tracks finite callable +alternatives and captures. Optimized lowering should use that existing +compiler data to defunctionalize the iterator state privately. + +### Current Public Shape + +The design now explicitly keeps the origin/main style public shape: + +```roc +Iter(item) :: { + len_if_known : [Known(U64), Unknown], + step : () -> [One({ item : item, rest : Iter(item) }), Skip({ rest : Iter(item) }), Done], +} + +Stream(item) :: { + len_if_known : [Known(U64), Unknown], + step! : () => [One({ item : item, rest : Stream(item) }), Skip({ rest : Stream(item) }), Done], +} +``` + +No iterator-specific plan IR, source-form rewrite, or public API change should +be introduced. + +## Optimized Callable-State Lowering Design + +### Why This Exists + +Roc iterators and streams are ordinary records containing ordinary callables. +If lowering materializes those records and callables literally, then a loop +such as: + +```roc +for point in iter { + ... +} +``` + +can carry a public `Iter` value, call a public step closure, allocate wrapper +state, rebuild rest iterators, refcount intermediate values, and retain helper +bodies. That is correct but not competitive with Rust-like optimized iterator +code. + +The optimizer should instead lower under exact demand. If the loop only needs +the step callable result, then lowering should ask the producer for that +callable and the demanded captures/results directly. It should not build the +whole public `Iter` record and then later remove it. + +### Mode Gate + +This optimizer runs only in optimized code-generation modes: + +- `--opt=size` +- `--opt=speed` + +It should not run during: + +- `roc check` +- compile-time evaluation +- dev builds +- interpreter preparation +- non-optimized lowering + +This matters because optimized callable-state lowering builds demand graphs, +sparse private-state tables, loop fixed-point data, and worker queues. Those +data structures should not exist in modes that do not request optimized code. + +### Core Data Concepts + +The current implementation vocabulary includes: + +- `ValueDemand`: what a consumer observes +- `KnownValue`: producer structure known to optimized lowering +- `DemandedKnownValue`: sparse demanded producer shape +- `PrivateStateValue`: optimized-only private state, not a public Roc value +- `PrivateStateCallable`: callable private state with demanded captures +- `LoopPattern`: ordinary loop specialization state +- `SparseStateLoopPattern`: optimized loop state with sparse demanded values +- `LoopLocalProvenance`: where a generated split local came from +- `DemandPathStep`: checked child identity path from a loop parameter + +The design intent is that `DemandedKnownValue` describes the state product and +`PrivateStateValue` describes the values used while cloning bodies. LIR should +not contain these concepts; LIR should only see ordinary scope-closed +statements and values. + +### Producer-Under-Demand + +The key rule is: + +```text +clone producers under exact consumer demand +``` + +For example, if code demands only `point.x`, then the producer should be cloned +under a record-field demand for `x`. If code demands a callable call result, +then demand should reach the callable result and the captures needed by the +callee body under that result. + +This avoids materializing public values that are immediately deconstructed. + +### Sparse Private State + +Private state is sparse. A missing child means "not carried." A present +unknown child means "carried as a runtime leaf." This is essential because +callable captures, tuple items, tag payloads, and record fields can be demanded +independently. + +Dense public shapes cannot represent: + +- capture 2 present and capture 0 absent +- tag choice present but payload absent +- only record field `x` carried +- only one tuple item carried + +Private state should be converted back to public values only at explicit +public observation boundaries. + +### Finite Callable Alternatives + +Lambda sets give finite callable alternatives. Different alternatives can have +different capture counts and capture indexes. Therefore capture demand cannot +be represented as one merged positional vector and applied to every +alternative. + +The verified invariant is: + +```text +callable capture demand must be keyed by the specific finite alternative being lowered +``` + +This fixed an earlier crash: + +```text +callable demand capture index exceeded lifted function capture count +``` + +The related tests included: + +```sh +zig build run-test-zig-lir-inline -- --test-filter "direct range map collect uses direct list loop" +zig build run-test-zig-lir-inline -- --test-filter "plant iter pipeline collect uses direct range map list loop" +zig build run-test-zig-lir-inline -- --test-filter "known-length List.iter collect specializes without unbound locals" +``` + +Those tests proved per-alternative capture demand and some generated-scope +closure cases. They did not prove the later loop-supplied capture invariant. + +### Loop Demand Graphs + +Iterator rest values are recursive. A step closure can return a `rest` iterator +that is structurally similar to the current loop-carried iterator. If optimized +lowering expands that structurally each time, it can grow forever: + +```text +iter -> step -> rest -> step -> rest -> ... +``` + +The correct representation is a loop-demand graph node. Demand can point back +to a loop parameter by graph identity rather than expanding an infinite tree. + +This is why recursive direct-call expansion is not acceptable. The recursion +must be represented explicitly in the demand graph. + +### Public Boundaries + +Sparse private state is not a public Roc value. Some boundaries require public +values: + +- non-inlined direct calls +- hosted calls +- backend-visible runtime calls +- ordinary public callable materialization +- stored constants and final LIR + +If a loop-carried value has both internal optimized uses and public observation +uses, the producer must know that before splitting it. Public materialization +must not attempt to reverse-engineer a full public value from sparse private +state after the fact. + +This invariant was exposed by one failed path where the compiler moved past +loop state selection and then crashed with: + +```text +sparse private state reached materialization +``` + +That failure was not fixed by adding materialization recovery. Instead, wrapper +analysis and public-boundary demand had to be clarified. + +### Solved Inline Wrapper Facts + +Some call-value wrappers are semantically transparent to optimized lowering. +However, they are not necessarily safe to inline in materialization contexts. + +The design became: + +- a `.call_value` wrapper can be optimized-inline eligible +- the same wrapper need not be materialize-inline eligible +- optimized structured-demand lowering may consume this fact +- late public-value lowering must not rediscover and inline the wrapper as a + cleanup pass + +The focused wrapper test passes: + +```sh +zig build run-test-zig-lir-inline -- --test-filter "call value wrapper is optimized-inline eligible but not materialize-inline eligible" +``` + +This fact moved the current imported-iterator regression past a public wrapper +boundary and into the missing capture invariant. + +### Loop Entry State Products + +The most recent committed fix addressed loop entry state identity. + +Before the fix, the focused imported-iterator regression crashed with: + +```text +postcheck invariant violated: optimized loop entry values could neither select a state nor be emitted as ordinary loop initials +``` + +The diagnostic shape was: + +```text +state 0: any leaf +entry: private_state(record) expr +``` + +That meant the state side had been keyed as an unknown public leaf while the +entry value side had already been demanded into sparse private record state. +Those cannot match, and the sparse private entry could not be emitted as an +ordinary public initial value. + +The fixed invariant is: + +```text +loop state products and entry products are a paired output of applying normalized demand to the original entry value +``` + +If applying demand to the loop entry produces sparse private state, the loop +state identity must be derived from that exact private-state shape. It must not +use a public `.any` placeholder of the same type. + +The committed implementation was intentionally narrow: + +```zig +const demanded_value = try self.applyValueDemand(value, demand); +if (demanded_value == .private_state) { + return try self.demandedKnownValueFromPrivateStateLoopStateShape(demanded_value.private_state); +} +``` + +This belongs in `demandedKnownValueFromLoopEntryDemand`, where the loop entry +product is being produced. It is not a materialization rule and not a call-site +rule. + +After that fix, the same focused test moved forward to: + +```text +postcheck invariant violated: sparse private callable was missing a demanded capture +``` + +That is the current active failure. + +## Failed Attempts And What They Taught + +### Browser-Driven Debugging Was Wasteful + +During the Rocci Bird game behavior investigation, localhost browser testing +was useful only as final validation. It was a poor primary debugging method. +The user explicitly called this out when the game was still visually corrupted. + +The better workflow is: + +1. reduce to a compiler regression +2. inspect LIR or disassembly only to classify codegen shape +3. fix the compiler invariant +4. then run the game in the browser once the targeted test passes + +### Public `Append` Variant Was The Wrong Direction + +Adding an iterator `Append` step variant tried to solve a private optimization +problem by changing public iterator shape. That violated the goal. It also +created confusing layout/compatibility problems because parts of the compiler +and library still expected the old step union shape. + +The lesson: + +```text +keep public Iter shape stable; make private optimized lowering better +``` + +### "Just Inline" Was The Wrong Direction + +Several failures tempted a local inline fix. For example, a sparse private +value reached a call boundary, or a wrapper exposed the real producer too late. +Inlining could sometimes move the failure further, but it would not establish +the missing compiler fact. + +The lesson: + +```text +if inlining is needed, the decision must be explicit producer data consumed at the right stage +``` + +Late inline cleanup is a hack if it exists only because earlier demand lowering +failed to represent the producer correctly. + +### Recursive Direct-Call Fallback Was The Wrong Direction + +When demand became recursive, one tempting idea was to recursively expand direct +calls or lower loops to direct recursive workers. That is not the long-term +design. Loops may interact with mutable variables and control regions; a +source-loop rewrite is not generally equivalent. + +The correct approach is a demand graph with explicit loop-demand nodes. + +### Clone-Site "Demand Changed, Try Again" Was The Wrong Direction + +A failed WIP added broad behavior where body cloning could notice state-loop +demand changes and return uninitialized values or retry. This included: + +- returning `bool` from `noteLoopDemandIfLocalExpr` +- clone-site uninitialized returns +- refreshing demanded known values from already-derived sparse private values +- mixed closure of function-demand refs and loop-demand refs +- broad late reaction to demand changes in consumers + +This moved failures around but did not represent the missing fact. It also made +non-convergence more likely. + +The failed WIP was deliberately deleted. The most recent commit +`ac8c4bda0e` includes that deletion along with the narrow state-key fix. + +The lesson: + +```text +demand growth must connect the demand owner to the original producer; consumers must not refresh missing pieces from sparse products +``` + +### Passing Tests That Do Not Fail First Are Not Regressions + +Some tests added during investigation passed before the production change. They +can be useful coverage, but they are not regressions for the missing invariant. + +The reset protocol now says: + +- record exact failing command +- record expected failure class +- ensure the failure is the intended invariant +- only then edit production code + +If a new test passes immediately, either keep it as secondary coverage or +delete it. Do not use it as proof for the fix. + +## Current Active Failure In Detail + +### Test + +The current active regression is: + +```sh +zig build run-test-zig-lir-inline -- --test-filter "imported iterator producer keeps finite step callables" +``` + +The test source shape is an imported module that exposes an iterator: + +```roc +module [points] + +Point : { x : I64 } + +points : () -> Iter(Point) +points = || [{ x: 1.I64 }, { x: 2 }].iter().append({ x: 3 }) +``` + +The consuming module imports that iterator and loops over it: + +```roc +module [main] + +import Points + +main : I64 +main = { + iter = Points.points() + var $sum = 0.I64 + for point in iter { + $sum = $sum + point.x + } + $sum +} +``` + +The test expects optimized lowering to avoid reachable erased callable lowering +for this simple iterator producer. + +### Previous Failure + +Before commit `ac8c4bda0e`, it failed at loop entry state selection: + +```text +postcheck invariant violated: optimized loop entry values could neither select a state nor be emitted as ordinary loop initials +``` + +That is fixed by deriving the loop state identity from the demanded private +entry shape. + +### Current Failure + +After the state-key fix, the same test fails at: + +```text +postcheck invariant violated: sparse private callable was missing a demanded capture +``` + +The stack reaches: + +```text +inlinePrivateStateCallableCallValueWithDemand +callKnownValueWithDemand +cloneExprValueWithDemand +inlineDirectCallValueWithDemand +cloneMatchScrutineeValue +cloneStateLoopFromDemandedKnownValues +``` + +Conceptually: + +1. The loop state carries an iterator as sparse private state. +2. The loop body calls the iterator's `step` callable. +3. Result demand from the step call reaches the callable body. +4. The callable body needs a capture under that result demand. +5. The sparse private callable does not have that capture in its carried + capture list. +6. The call-site consumer crashes because it cannot bind the capture. + +The right fix is not to make `inlinePrivateStateCallableCallValueWithDemand` +invent the missing capture. At that point, the consumer is too late. The +private callable producer should have represented the demanded capture. + +### Why The Capture Is Special + +For ordinary captures, the private callable can carry a sparse captured value: + +```text +capture index N -> private state child +``` + +For recursive iterator state, the demanded capture may be the current loop +state itself or a demanded path inside it. Carrying it structurally inside the +callable would recursively include the iterator inside its own step closure, +causing unbounded expansion. + +The long-term design is a third option: + +```text +capture index N -> supplied by active loop state at path P +``` + +This is neither omitted nor carried as a runtime leaf. It is an explicit +private-state supplier. + +## Loop-Supplied Callable Capture Design + +### Problem Statement + +When a loop-carried callable's body needs a capture that is already owned by +the same active loop state, storing that capture structurally inside the +callable duplicates recursive state and can cause infinite demand growth. But +omitting the capture is wrong because the callable body needs it. + +The correct representation is: + +```text +this demanded callable capture is supplied by the active loop state +``` + +### Required Data + +A supplier needs to identify: + +- the active loop fixed point that owns the state +- the original loop parameter identity +- the demanded path from that loop parameter to the supplied value +- the type of the supplied value +- the demand that must be merged back into the owning loop parameter + +The path uses checked child identities: + +- record field name +- tuple item index +- tag payload name and index +- nominal backing +- callable capture index + +The current code already has: + +```zig +const LoopLocalProvenance = struct { + local: Ast.LocalId, + source_local: Ast.LocalId, + path: []const DemandPathStep, +}; + +const DemandPathStep = union(enum) { + record_field: names.RecordFieldNameId, + tuple_item: u32, + tag_payload: struct { + name: names.TagNameId, + index: u32, + }, + nominal_backing, + callable_capture: u32, +}; +``` + +That is the right foundation. It records when generated split locals come from +a path inside an original loop parameter. + +### Producer Responsibility + +The producer of demanded private callable state must decide whether a demanded +capture is: + +1. not demanded +2. carried as ordinary private state +3. carried as a runtime leaf +4. supplied by active loop state + +If it is supplied by loop state, the producer must also merge the capture's use +demand back into the owning loop parameter at the recorded path: + +```text +loop_param_demand = merge(loop_param_demand, demandAtPath(path, capture_demand)) +``` + +After that merge, the supplier itself contributes no state slots. It is a +reference to state already carried by the loop. + +### Consumer Responsibility + +When inlining the private callable inside the owning loop fixed point, the +consumer binds the source capture local from the active loop state at the +supplier path. + +This should happen in the private callable inlining path, currently around: + +```zig +inlinePrivateStateCallableCallValueWithDemand +``` + +But the consumer should not infer the supplier. It should only consume explicit +supplier data placed in the private callable state by the producer. + +### Where Supplier Data May Appear + +Supplier data is legal only inside optimized private callable state owned by +the active loop fixed point. + +It is illegal at: + +- public materialization boundaries +- worker boundaries unless explicitly represented as parameters first +- ordinary public callable materialization +- stored constants +- LIR +- ARC +- backends + +If a supplier reaches those boundaries, that is a compiler bug. + +### Why This Is Not A Fallback + +This is not "if capture missing, go find it." That would be a fallback and a +call-site guess. + +The producer must explicitly store: + +```text +capture index N has supplier S +``` + +The consumer then sees capture index N in the callable state and binds it. A +missing capture remains an invariant violation. + +### Likely Implementation Shape + +The implementation probably needs a new private-state shape, conceptually: + +```zig +const PrivateStateValue = union(enum) { + leaf: PrivateStateLeaf, + tag: PrivateStateTag, + record: PrivateStateRecord, + tuple: PrivateStateTuple, + nominal: PrivateStateNominal, + callable: PrivateStateCallable, + supplied: PrivateStateLoopSupplier, + compact_finite_tags: PrivateStateCompactFiniteTags, + compact_finite_callables: PrivateStateCompactFiniteCallables, +}; +``` + +The actual name should follow the repository's naming rules. `Ref` and `Key` +suffixes are banned in new post-check code, so avoid names like +`LoopSupplierRef`. A name like `PrivateStateLoopSupplier` or +`LoopSuppliedState` is closer to the local vocabulary. + +The supplier struct likely needs: + +```zig +const PrivateStateLoopSupplier = struct { + ty: Type.TypeId, + source_local: Ast.LocalId, + path: []const DemandPathStep, +}; +``` + +It may also need an active loop identity beyond `source_local`, depending on +how nested loops and state-loop stacks are represented. The design says it is +keyed by active loop fixed point, original loop parameter identity, and path. +If active loop identity is implicit in the stack while supplier values are +created and consumed inside the same clone, `source_local + path` might be +sufficient for the first implementation. If suppliers can survive into worker +state or nested contexts, an explicit owner identity is required. The code +should not rely on incidental stack position if that value can cross a +boundary. + +### Functions That Need To Understand Suppliers + +Adding a private-state supplier is not just one match arm. Every function that +traverses `PrivateStateValue` must either: + +- handle it explicitly, or +- reject it with an invariant because that boundary is illegal + +Relevant functions include: + +- private-state type queries +- private-state public materialization checks +- private-state matching against demanded known values +- demanded-known derivation from private state +- private-state argument counting +- private-state argument construction +- expression extraction from demanded known values +- local-demand propagation through private state +- value may-demand-local checks +- private callable capture lookup +- private callable inlining +- compact finite callable handling +- state continue splitting +- state loop key matching + +This is why the next implementation should be careful and test-driven. A +supplier value is zero-slot state; it should not accidentally allocate a worker +argument or become a leaf. + +### Tests Needed Before Production Edits + +The current imported-iterator regression is a valid existing failing test for +the broad invariant, but the next production change should ideally also add a +smaller focused regression that proves supplier behavior directly. + +The focused test should show: + +- a loop-carried callable/iterator +- a callable result demand that demands a capture +- the capture is the current loop state or a path inside it +- lowering converges without structural expansion +- the resulting LIR is scope-closed +- no public callable materialization is used as recovery +- no erased callable lowering remains reachable in the hot path + +The test should not rely on WASM-4, Rocci Bird, browser behavior, or binary +size. It should be a compiler test in the LIR inline/specialization area. + +The existing imported-iterator test can remain the integration-style compiler +regression for imported modules. + +## File-Level Implementation Notes + +### Main File + +Most current work is in: + +```text +src/postcheck/monotype_lifted/spec_constr.zig +``` + +This file is already large and contains both older SpecConstr concepts and the +new optimized callable-state lowering machinery. This is risky because local +fixes can easily become hacks. The design docs and plan reset protocol are +important guardrails. + +### Important Existing Types + +Known producer shapes: + +```zig +const KnownValue = union(enum) { + any, + leaf, + tag, + record, + tuple, + nominal, + callable, + finite_tags, + finite_callables, +}; +``` + +Demanded sparse producer shapes: + +```zig +const DemandedKnownValue = union(enum) { + any, + leaf, + tag, + record, + tuple, + nominal, + callable, + finite_tags, + finite_callables, + compact_finite_tags, + compact_finite_callables, +}; +``` + +Private optimized state: + +```zig +const PrivateStateValue = union(enum) { + leaf, + tag, + record, + tuple, + nominal, + callable, + compact_finite_tags, + compact_finite_callables, +}; +``` + +The likely next change is to extend `PrivateStateValue` with a supplier shape +or extend callable captures with a supplier-capable value. A separate capture +union may be cleaner than making all private state values supplier-capable, but +the supplier can represent any demanded child path, so a general +`PrivateStateValue` variant may be simpler. + +### Demand Propagation Helpers + +Important helpers: + +```zig +noteLoopDemandIfLocalExpr +mergeActiveStateLoopParamDemand +mergeLoopValueParamDemand +normalizeLoopValueParamDemand +demandAtPath +demandForSplitLocal +mergeProjectedPrivateStateDemand +mergeLocalDemandInPrivateStateValueAtPath +``` + +The current demand propagation already knows how to project missing private +state children back to a loop parameter through provenance: + +```zig +mergeProjectedPrivateStateDemand(local, subst_local, path, demand, out) +``` + +That is close to the producer side needed for suppliers. The missing part is +that the private callable state itself must carry a supplier value for the +capture rather than omitting it. + +### Current Inlining Failure Point + +The current crash happens in: + +```zig +inlinePrivateStateCallableCallValueWithDemand +``` + +The relevant logic currently says: + +```zig +if (privateStateIndexedValueByIndex(callable.captures, index)) |capture| { + bind capture from private state +} else { + capture_demand = ... + if capture_demand != .none { + capture_value = privateStateCallableCaptureValue(callable, index) + orelse invariant("sparse private callable was missing a demanded capture") + bind capture from capture_value + } +} +``` + +This should remain an invariant for truly missing captures. The long-term fix +is that a loop-supplied capture is not missing; it is present as supplier data +and can be resolved explicitly. + +### State Key Fix Location + +The state-key fix is in: + +```zig +demandedKnownValueFromLoopEntryDemand +``` + +It now derives a demanded-known loop shape from the actual demanded private +entry value when `applyValueDemand` returns private state. That was the right +producer location for that invariant. + +## What Has Worked Well + +### Explicit Invariants + +The best progress happened when a failure was named as a compiler invariant: + +- cross-alternative callable demand +- generated-scope leak +- public-boundary demand +- stale demanded product +- loop-state key mismatch +- missing demanded callable capture + +Once named, the implementation could be narrow and testable. + +### Focused Compiler Tests + +Focused Zig tests in `lir_inline_test.zig` were much better than browser +testing or Rocci Bird-only checks. They made it possible to distinguish: + +- "moved past one invariant" +- "hit the next invariant" +- "introduced a non-convergence bug" +- "changed behavior but did not prove the intended fact" + +### Deleting Bad WIP + +Deleting failed WIP was necessary. The branch improved when bad partial paths +were removed instead of being kept as possible ingredients. + +Commit `ac8c4bda0e` is an example: it both removed the failed clone-site demand +change experiment and kept the narrow loop-entry state shape fix. + +### Design.md As A Contract + +Updating `design.md` before code helped keep the work honest. It forced a +producer/consumer contract to be written down instead of inferred from the +current crash. + +### Comparing Against Rust + +The Rust port was valuable because it clarified the generated-code target. +Rust showed that the small-code path is possible, but also made clear that Roc +needs a different route because Roc's public type model is different. + +## What Has Not Worked Well + +### Optimizing From Rocci Bird Symptoms + +Rocci Bird is a great integration target, but it is too large as the primary +debugging surface. Browser behavior and final WASM size are useful final +checks, not first principles. + +### Source-Shape Thinking + +Any thought process that starts with "when we see `.iter()`" or "inside a +`for` loop" tends to produce the wrong design. The optimizer should operate on +checked values, lambdas, demand, and loops after earlier compiler phases. + +### Public API Changes For Private Optimization + +The `Append` experiment showed that changing public iterator shape creates +more problems and violates the goal. The public shape should stay stable. + +### Late Cleanup Thinking + +The user correctly objected to layered cleanup passes. Some cleanup may be +generic and contract-driven, such as Binaryen or zero-segment removal under an +explicit zeroed-memory contract. But cleanup should not compensate for missing +compiler facts when the producer stage could emit the right representation. + +### Broad Retry Logic + +Broad "demand changed, return uninitialized, try again" logic made the code +less principled and created non-convergence risk. Demand fixed points should +converge because graph identity and equality are correct, not because random +clone sites bail out. + +## Lessons Learned + +### Demand Graphs Must Be Stable By Meaning, Not Allocation + +Demand fixed points should not grow because a new node was allocated, entries +were reordered, or temporary provenance differs. Equality must be semantic. +Iteration caps are debug assertions for compiler bugs, not optimization +policy. + +### Sparse State Must Always Know Its Original Producer + +A sparse private product is derived. It is not a source of truth. If demand +grows, rebuild from the original producer under the normalized new demand. +Do not try to refresh missing fields or captures from the sparse value. + +### Public Observation Must Be Explicit + +If a value crosses a public boundary, the producer must know that and carry a +public value or public leaf as needed. Sparse private state cannot be +materialized magically later. + +### Wrapper Facts Are Contextual + +A wrapper can be transparent for optimized structured demand but not for public +materialization. Inline facts need context. A fact consumed in the wrong stage +can erase useful destination-passing, Box update, ARC, or private-state +boundaries. + +### Loop-Supplied Captures Are A Real Third Case + +Callable captures are not only "stored" or "omitted." Recursive iterator state +needs "supplied by active loop state" as an explicit private-state case. This +is the next missing compiler fact. + +### Hoisting Should Ignore Source-Level Wrapper Categories + +Nominal, opaque, and structural distinctions should not decide hoisting. The +real questions are whether the expression is resolved/valid enough, whether it +depends on runtime inputs, and whether it calls effectful functions. + +### Browser Testing Is Final Validation + +Browser testing should happen only after compiler invariants are proven. It is +too noisy for root-cause work. + +## Recommended Next Steps + +### 1. Keep The Current State-Key Fix + +Do not revert commit `ac8c4bda0e`. It fixed the loop-entry state identity +invariant and moved the focused regression to the next expected failure. + +### 2. Add A Focused Supplier Regression + +Before production edits for suppliers, add or identify a smaller compiler test +than the imported iterator test. It should fail with the current missing +capture invariant or a more direct supplier invariant. + +The existing imported iterator test remains the broad regression: + +```sh +zig build run-test-zig-lir-inline -- --test-filter "imported iterator producer keeps finite step callables" +``` + +### 3. Add Explicit Supplier Data + +Represent a demanded callable capture supplied by active loop state as explicit +private-state data. Do not infer it in `inlinePrivateStateCallableCallValueWithDemand`. + +The supplier should be produced from loop provenance and demand, not from: + +- source names +- debug ids +- current subst contents alone +- structural equality with some private value +- call-site failure recovery + +### 4. Merge Supplier Demand Into The Owning Loop Parameter + +When the producer creates supplier data, it must also merge the capture demand +back into the loop parameter at the supplier path. + +This is how the loop state knows it must carry the supplied value. + +### 5. Resolve Supplier Only In The Owning Loop + +The private callable inliner should resolve supplier data by reading active +loop state at the recorded path. If the owning loop is unavailable, that is an +invariant violation. + +### 6. Audit All Private-State Traversals + +Every `PrivateStateValue` traversal must handle or reject supplier data. +Especially audit: + +- materialization +- argument counting +- demanded-known conversion +- matching/equality +- local-demand propagation +- state continue splitting +- compact finite callables +- LIR emission boundaries + +### 7. Rerun Focused Tests + +Minimum focused checks after supplier implementation: + +```sh +zig build run-test-zig-lir-inline -- --test-filter "imported iterator producer keeps finite step callables" +zig build run-test-zig-lir-inline -- --test-filter "call value wrapper is optimized-inline eligible but not materialize-inline eligible" +zig build run-test-zig-lir-inline -- --test-filter "direct range map collect uses direct list loop" +zig build run-test-zig-lir-inline -- --test-filter "plant iter pipeline collect uses direct range map list loop" +zig build run-test-zig-lir-inline -- --test-filter "known-length List.iter collect specializes without unbound locals" +``` + +Then run the broader LIR inline target once focused tests pass. + +### 8. Only Then Return To Rocci Bird + +After the compiler invariants pass: + +- rebuild Rocci Bird with `--opt=size` or the current equivalent size mode +- run Binaryen size optimization +- compare `.iter()` and non-`.iter()` versions again +- compare against the Rust port +- inspect disassembly only to explain remaining size differences +- run the browser server only as final validation + +## Known Open Questions + +### Exact Supplier Owner Identity + +The design says a supplier is keyed by active loop fixed point, original loop +parameter identity, and path. The existing code has `source_local` and path +provenance. It needs to be decided whether `source_local + active stack` is +enough in the implementation or whether an explicit loop owner id must be +added. + +Long-term ideal answer: use explicit owner identity if there is any chance the +supplier can cross nested loop or worker boundaries where stack position is not +sufficient. + +### Supplier As PrivateStateValue Variant Or Capture-Specific Variant + +Two possible shapes: + +1. Add a general `PrivateStateValue` supplier variant. +2. Change callable captures from `[]PrivateStateIndexedValue` to a + capture-specific union that can hold either private state or supplier data. + +The first is simpler and makes supplier data usable for any demanded child. The +second may prevent supplier values from appearing where they are illegal. The +implementation should choose the shape that best encodes the invariant without +requiring scattered runtime checks. + +### Worker Boundary Behavior + +Supplier data is legal only inside the owning loop fixed point. If optimized +workers need to cross that boundary, the supplier must be converted into +explicit parameters before the boundary. The current focused regression may not +need this, but the design should not leave an ambiguous fallback. + +### Interaction With Public-Boundary Demand + +Some values may need both sparse private internal state and public boundary +state. Supplier implementation should not regress the public-boundary +invariant. Public materialization must still be explicit. + +### Interaction With Box Reuse And Destination Passing + +The broader size gap includes Box/update and destination-passing opportunities. +Supplier work is not the whole story. Once iterator/callable state lowers +properly, the next major size/performance wins may come from: + +- updating through unique boxes +- destination-passing for returned aggregates/strings/lists +- better in-place update representation +- boxed lambda update where safe + +Those are separate compiler designs and should not be mixed into the supplier +slice. + +## Final Status + +The project has made real progress: + +- WASM-4 memory waste was diagnosed and redesigned around an explicit memory + contract. +- Binaryen was identified as necessary normal WASM tooling and integrated + through the bootstrap direction. +- Rocci Bird was made to run again in optimized builds after earlier + corruption/miscompile work. +- Several app and compiler size wins were implemented. +- The remaining Rust-size gap was narrowed to a real compiler design problem: + optimized lowering of callable/iterator state. +- The public iterator API direction is now clear: keep the three-step + lambda-based public shape and optimize privately using lambda sets. +- Several bad implementation directions were tried, identified, and deleted. +- The latest committed compiler slice fixed loop entry state identity and moved + the active regression to the next real missing fact. + +The next long-term-ideal task is explicit loop-supplied callable captures. That +should be implemented as producer-owned private-state data, with a focused +regression proving convergence and scope-closed LIR before any return to +Rocci Bird binary-size work. diff --git a/size_forensics.md b/size_forensics.md new file mode 100644 index 00000000000..258af57e239 --- /dev/null +++ b/size_forensics.md @@ -0,0 +1,268 @@ +# Rocci Bird `--opt=size` forensics: where the bytes went (Slice G vs checkpoint) + +> Historical investigation. These measurements compare old compiler +> checkpoints and are retained as guidance about the failed shapes they +> exposed; they are not the current cart baseline. Current measurements and +> conclusions are recorded in `iter_fusion_design.md` and `plan.md`. + +Measurement task under the Slice-G measurement ruling. No optimizer code was +changed; only temporary probes (since removed) and this report. Numbers are +from four freshly built `--opt=size` wasm carts: + +| variant | checkpoint `57b0541c66` | current `b0069f306c` | delta | +|---|---:|---:|---:| +| iter (`rocci-bird.roc`) | 94,455 | 103,548 | **+9,093** | +| noiter (`rocci-bird-noiter-check.roc`) | 92,940 | 96,234 | **+3,294** | + +(File sizes differ from the plan's 94,448 / 92,933 / 103,544 / 96,230 by 4-8 +bytes of embedded build hash; the deltas are exact.) The two source files are +identical except line 358: iter has `].iter()`, noiter has `]`. + +## Section-level accounting: it is all code + +| section | iter ckpt -> G | noiter ckpt -> G | +|---|---|---| +| **data** | 33,622 -> 33,622 (**0**) | 33,622 -> 33,622 (**0**) | +| **code** | 55,477 -> 64,618 (**+9,141**) | 54,028 -> 57,483 (**+3,455**) | +| custom (names) | 4,162 -> 4,118 (-44) | 4,100 -> 3,946 (-154) | +| everything else (type/import/func/table/global/export/elem) | ~1 net | ~0 net | + +The data segment is byte-identical. The whole regression lives in the code +section. Function counts actually *dropped* (iter 177 -> 173, noiter 173 -> +165): fewer functions, more code. The growth is **concentration** - surviving +functions absorbed per-site machinery via inlining/scalarization. + +Builtins, host shims, allocator, and refcount thunks align by name and are +byte-stable: matched-by-name delta is **-31 B (iter)** / **-58 B (noiter)**, +dominated by `roc_llvm_rc_decref_109` shrinking. Every regressing byte is in +the `roc__proc_*` set. The `roc__proc_` suffixes renumber wholesale +between builds (checkpoint 2xx/3xx, current 4xx/5xx), so procs were aligned by +(1) stable wasm **function index**, (2) `host_*` call signature, and (3) size/ +shape. All three agree. + +## Top-20 functions per binary (body bytes, function index, name) + +### current iter (G) +``` + 8351 #119 proc_4aa 2577 #124 proc_4cf 831 #147 proc_4ff + 7406 #82 proc_47b 2272 #92 proc_4a2 817 #153 proc_4fe + 4321 #132 proc_4ec 2198 #127 proc_4a1 806 #90 proc_4a8 + 4226 #84 proc_47a 1854 #81 proc_47c 752 #31 host.ummRemap + 3688 #129 proc_4a5 1761 #120 proc_4a9 662 #23 allocator.malloc + 2577 #85 proc_47d 1279 #74 proc_46f 618 #108 proc_4cb + 880 #176 list.listReserve 831 #139 proc_502 +``` +### checkpoint iter +``` + 8351 #131 proc_351 2577 #140 proc_360 818 #134 proc_355 + 3956 #84 proc_2e6 2272 #92 proc_30e 806 #90 proc_314 + 3688 #128 proc_311 2198 #126 proc_30d 752 #31 host.ummRemap + 3066 #82 proc_2e7 1761 #132 proc_350 685 #154 proc_36c + 2577 #85 proc_2e9 1384 #81 proc_2e8 662 #23 allocator.malloc + 1279 #74 proc_2db 618 #108 proc_334 + 880 #180 list.listReserve 618 #111 proc_32f 532 #120 proc_315 +``` +### current noiter (G) +``` + 8351 #119 proc_3d5 2577 #124 proc_3fa 806 #90 proc_3d3 + 7406 #82 proc_3a6 2272 #92 proc_3cd 752 #31 host.ummRemap + 4226 #84 proc_3a5 2198 #127 proc_3cc 662 #23 allocator.malloc + 3688 #129 proc_3d0 1854 #81 proc_3a7 618 #108 proc_3f6 + 2577 #85 proc_3a8 1761 #120 proc_3d4 618 #111 proc_3f1 + 1279 #74 proc_39a 447 #125 proc_3ff + 1029 #132 proc_417 880 #168 list.listReserve +``` +### checkpoint noiter +``` + 8351 #131 proc_350 2577 #140 proc_35f 806 #90 proc_313 + 3956 #84 proc_2e5 2272 #92 proc_30d 752 #31 host.ummRemap + 3688 #128 proc_310 2198 #126 proc_30c 662 #23 allocator.malloc + 3066 #82 proc_2e6 1761 #132 proc_34f 618 #108 proc_333 + 2577 #85 proc_2e8 1384 #81 proc_2e7 618 #111 proc_32e + 1279 #74 proc_2da 532 #120 proc_314 + 897 #134 proc_354 880 #176 list.listReserve +``` + +## Aligned delta table (index/role-anchored) + +| role | fn idx | ckpt | current | delta | note | +|---|---|---:|---:|---:|---| +| `update!` game-state machine | #82 | 3,066 | 7,406 | **+4,340** | loops 1->2; rc-call sites 18->71 | +| draw (blit/rect/text) | #84 | 3,956 | 4,226 | +270 | | +| draw #81 | #81 | 1,384 | 1,854 | +470 | | +| `on_screen_collided!` (iter) | - | 93 | 4,321 | +4,228 | body-only fn -> 3-loop fused fn | +| `on_screen_collided!` (noiter)| - | 897 | 1,029 | +132 | single list loop, no split | +| big frame fn #119 | #119| 8,351 | 8,351 | 0 | unchanged | +| two 2,577 workers #85/#124 | - | 5,154 | 5,154 | 0 | present in both builds | +| plain iterator loop sites | - | - | - | **negative** | scalarized form is smaller (see marginals) | + +The low wasm function indices are stable across builds, which nails the +identity of the two biggest movers: **#82 = `update!` grew +4,340** and it is +present, identical in size and shape, in *both* iter and noiter. That single +function is the largest regression term and it is **not** the collision loop. + +## Quantified answers to (a)-(d) + +### (a) Per-site loop machinery vs removed shared workers (iter) + +The checkpoint drove the branch-chosen collision iterator through **shared +generic Iter workers**; current fuses per branch. The exchange: + +- **Removed** (checkpoint-only Iter dispatch/append/next workers): 13 functions + totalling **4,343 B** (818, 685, 532, 406, 320, 296, 283, 268, 235, 219, 134, + 101, 46) plus the 93 B body function. +- **Added** (current per-branch step workers): 7 functions totalling **3,964 B** + (831, 831, 817, 519, 322, 322, 322), plus the collision function itself + ballooning from a 93 B body to a **4,321 B** three-loop function. +- Net collision-area cost: **(3,964 + 4,321) - (4,343 + 93) = +3,849 B**, iter only. + +Fused loop sites in `proc_4ec`: **3** (one `loop` per branch: append-two, +append-one, base), each with its own step-worker call and an inlined get_pixel +body (`proc_504`, called 3x). + +### (b) The two 2,577 B workers: same size, NOT byte-identical + +`proc_47d` and `proc_4cf` are both 2,577 B but differ in **26 of 761 lines** - +store offsets (272 vs 264; 72 vs 80; a field at offset 168 placed differently) +and constants (17/6 vs 6/12). They are two **layout-specialized** copies of one +generic worker, each called exactly once (from `proc_472` and `proc_47c`). They +exist unchanged in the checkpoint too, so they are **not part of the +regression**. A naive byte-dedup will not fire; reclaiming them needs +re-generalizing the worker over its field layout (offsets passed as data). + +The real near-duplicates are two smaller trios (see rank 3): the **831-byte +trio** (`proc_502` vs `proc_4ff` differ in **2 lines**; `proc_4fe` differs in +12) and the **322-byte trio** (`proc_500/501/503` differ in exactly **one +constant** - a byte tag `2` / `3` / `5` stored at `offset=56`). + +### (c) Dead `len_if_known` recompute + +The append step worker `proc_502` computes a known-length tag twice per call: +`i32.load8_u offset=8` (inner tag) -> `i64.load; i64.const 1; i64.add` +(inner_len + 1) -> `i64.eqz` (is-empty), then stores that byte + i64 into the +freshly allocated iterator state (`offset=16` / `offset=8`). The consuming +for-loop only tests `index == len` at the top of the loop and never reads +`len_if_known`, so the field, its per-iteration recompute, and its refcount +traffic are dead. Whole-binary `i64.eqz` count (a proxy for this recompute) +went **4 -> 9 (iter)**: roughly **+5 recompute sites**, ~15-30 B each including +the carried-state load/store, i.e. an estimated **~150-350 B**. Small. + +### (d) Unexpected: refcount-operation proliferation is the real bulk + +Whole-binary `call $roc_llvm_rc_*` sites: + +| | checkpoint | current | delta | +|---|---:|---:|---:| +| iter | 175 | 302 | **+127** | +| noiter | 169 | 238 | **+69** | + +Inside `update!` (#82) alone the count goes **18 -> 71**. When a shared worker +owned the iteration, refcounts were managed once behind the call boundary; +inlining/scalarizing the loop bodies into their callers re-exposed per-element +incref/decref that did not get elided, and duplicated it across the two +game-state arms (the inner loop appears twice, at lines 498 and 992 of +`proc_47b`). This is the mechanism behind the +4,340 `update!` term and it is +present in **noiter too** (no iterators involved), which is why noiter grew even +though its collision loop barely changed (+132). + +## Marginal per-site cost (isolated probes, both compilers, N=1/3/5) + +Distinct-constant loop sites, `--opt=size`, file bytes; slope = (N5 - N1)/4: + +| loop pattern | current B/site | checkpoint B/site | verdict | +|---|---:|---:|---| +| plain iterator `for n in list.iter()` | **418** | **526** | current **saves 108/site** | +| collect / keep-if `for n in l { if c { $out=$out.append } }` | **721** | **721** | **unchanged** (byte-identical carts at every N) | +| branch-chosen append (collision shape) | **1,352** | **811** | current **costs +541/site** | + +Fixed (N=1) premium of the branch-append shape over checkpoint: ~1,800 B; it +then grows +541 per added site (linear across N=1,3,5). Plain scalarized loops +are a **net win** and the win scales; the collect pattern did not change; only +the **branch-chosen-append** shape regresses per site. + +## Ranked reclaim list + +1. **Refcount elision after inline/scalarize** - structural, largest, both + variants. +127 rc sites (iter) / +69 (noiter); ~53 of them in `update!` + alone. Owner: the refcount-insertion / ownership pass. Sketch: when a loop + body or step worker is inlined and its carried operands are provably linear + (single owner, consumed once), own them in place and drop the surrounding + incref/decref instead of re-emitting per-iteration RC traffic. **Est. reclaim + ~1,500-2,500 B iter, ~1,000-1,500 B noiter.** This is the *only* lever on the + noiter regression and roughly half of iter's. + +2. **Branch-append loop-fission / shared-core peel** - structural, iter-only. + Collapses `for e in (if c {append(base,x)} else {base}) {BODY}` into + `for e in base {BODY}; if c {BODY[item:=x]}`, replacing the 3 per-branch + loops + their step-worker trios with one base loop and a tail dispatch. + Owner: the loop-split/fusion pass (the escalated FACT 2). Removes the + +541 B/site marginal and most of the +3,849 collision-area cost. + **Est. reclaim ~2,500-3,500 B iter-only** (0 for noiter). + +3. **Congruence-keyed step-worker dedup** - surgical, iter. The 831-trio + (`proc_502` vs `proc_4ff`: 2-line diff) and the 322-trio (`proc_500/501/503`: + one baked byte-tag `2/3/5`) are near-congruent. Owner: the branch-split + step-worker emission. Sketch: key emitted workers by structural congruence + *up to baked constants and field offsets*; emit one worker parameterized by + the differing constant (or carry it in state). **Est. reclaim ~1,600 (831 + pair) + ~644 (322 pair) = ~2,240 B iter.** + +4. **Dead `len_if_known` field + recompute** - surgical, small. Owner: iterator- + state lowering. Sketch: liveness on loop-carried state fields - if + `len_if_known` is read by neither the loop condition, body, nor result, drop + the field, its per-iteration `inner_len+1 / eqz` recompute, and its RC. + **Est. reclaim ~150-350 B.** + +5. **Re-generalize the two 2,577 B layout-specialized workers** - pre-existing, + not part of this regression. Same size, different field offsets; needs the + worker parameterized over layout. Owner: monomorphization/specialization + policy. **Est. reclaim up to ~2,577 B** but a larger refactor; deprioritized. + +## Verdict: (iii) - a per-site-vs-shared policy tension + +Not (i): the surgical facts (ranks 3+4 ≈ 2,400-2,600 B) reclaim well under a +third of the +9,093 iter regression and **essentially none** of the +3,294 +noiter regression. Not (ii): the peel (rank 2) is iter-only - it reclaims ~0 of +noiter and is not even a majority of iter. The dominant single term (+4,340 +`update!`, rank 1) is refcount/inline proliferation that hits **both** variants +and that neither the peel nor the surgical facts touch. + +The marginal probes make the tension explicit: the same pipeline that makes +plain scalarized loops **smaller** (-108 B/site) makes branch-append loops +**larger** (+541 B/site) and neutral on collect. Per-site emission is a win when +the carried state is scalar and linear, and a loss when it retains boxed/owned +carry, congruent-but-duplicated workers, or un-elided refcount traffic. + +**Proposed structural rule (not a byte threshold):** + +> Emit per-site iteration machinery only when the loop's carried state is +> *scalar-linear*: every carried leaf demotes to a scalar (no boxed/owned +> carry) **and** the per-iteration refcount traffic provably nets to zero. +> A site that fails this gate routes through a shared worker. Emitted workers +> are deduplicated by **structural congruence** (identical up to baked constants +> and field offsets), emitting one worker parameterized by the differing datum. +> For branch-chosen sources, the shared-core / loop-fission form is the default; +> fully split into per-branch loops only when the branch cores are structurally +> disjoint. + +The peel remains the right fix for the collision premium specifically, but the +numbers say it must be paired with rank-1 refcount elision to bring both carts +back under the checkpoint - the peel alone leaves the +4,340 `update!` term +(and all of noiter) standing. + +## Surprising findings + +- **noiter grew with no iterators involved.** Its collision loop barely moved + (+132); the growth is the shared `update!` +4,340 from RC/inline proliferation. +- **Plain-loop scalarization is a net size win** (-108 B/site) that scales - the + regression is not "fusion is bigger," it is specifically the branch-append + shape plus refcount churn. +- **Fewer functions, more code**: both current carts define fewer functions than + the checkpoint yet ship more code - pure concentration by inlining. +- The two "duplicated 2,577 B workers" flagged in the plan are same-size but + **not** byte-identical and **predate** this regression; the actual dedup wins + are the smaller 831- and 322-byte trios. +- **`--debug` is not code-identical**: the `--opt=size --debug` build carries 23 + extra helper functions (+1,696 code bytes) versus the shipped build, so it is + the wrong proxy for size work. The shipped `--opt=size` build already carries + a function-name section, which is what these tables use. diff --git a/src/backend/dev/LirCodeGen.zig b/src/backend/dev/LirCodeGen.zig index 9fb156308d6..6e0f90f2f44 100644 --- a/src/backend/dev/LirCodeGen.zig +++ b/src/backend/dev/LirCodeGen.zig @@ -252,6 +252,7 @@ pub const BuiltinFn = enum { list_decref_flat_list, list_free_with, list_free_flat_list, + box_prepare_update, box_decref_with, box_decref_with_single_thread, box_free_with, @@ -405,6 +406,7 @@ pub const BuiltinFn = enum { .list_decref_flat_list => "roc_builtins_list_decref_flat_list", .list_free_with => "roc_builtins_list_free_with", .list_free_flat_list => "roc_builtins_list_free_flat_list", + .box_prepare_update => "roc_builtins_box_prepare_update", .box_decref_with => "roc_builtins_box_decref_with", .box_decref_with_single_thread => "roc_builtins_box_decref_with_single_thread", .box_free_with => "roc_builtins_box_free_with", @@ -819,6 +821,8 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Readonly data symbols for non-SSO strings in object-file output. static_strings: []const StaticStringData.Entry, + /// Owned names for generated internal static-data relocation targets. + static_data_symbol_names: std.ArrayList([]u8), /// Map from LIR local id to value location (register or stack slot) local_locations: std.AutoHashMap(u32, ValueLocation), @@ -1202,6 +1206,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { .store = store, .layout_store = layout_store_opt, .static_strings = static_strings, + .static_data_symbol_names = .empty, .local_locations = std.AutoHashMap(u32, ValueLocation).init(allocator), .join_points = std.AutoHashMap(u32, usize).init(allocator), .stmt_locations = std.AutoHashMap(u32, usize).init(allocator), @@ -1237,6 +1242,8 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Clean up resources pub fn deinit(self: *Self) void { self.codegen.deinit(); + self.clearStaticDataSymbolNames(); + self.static_data_symbol_names.deinit(self.allocator); self.local_locations.deinit(); self.join_points.deinit(); self.stmt_locations.deinit(); @@ -1271,6 +1278,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Reset the code generator for generating a new expression pub fn reset(self: *Self) void { self.codegen.reset(); + self.clearStaticDataSymbolNames(); self.local_locations.clearRetainingCapacity(); self.join_points.clearRetainingCapacity(); self.stmt_locations.clearRetainingCapacity(); @@ -1298,6 +1306,13 @@ pub fn LirCodeGen(comptime target: RocTarget) type { self.loop_break_patches.clearRetainingCapacity(); } + fn clearStaticDataSymbolNames(self: *Self) void { + for (self.static_data_symbol_names.items) |name| { + self.allocator.free(name); + } + self.static_data_symbol_names.clearRetainingCapacity(); + } + fn cloneJoinPointJumpsMap( self: *Self, source: *const std.AutoHashMap(u32, std.ArrayList(JumpRecord)), @@ -4383,6 +4398,59 @@ pub fn LirCodeGen(comptime target: RocTarget) type { return .{ .stack = .{ .offset = result_offset, .size = ValueSize.fromByteCount(elem_size) } }; } }, + .box_prepare_update => { + // box_prepare_update(box) -> Box(value): consume one box + // reference and return a unique box payload for mutation. + const ls = self.layout_store; + const ret_layout_data = ls.getLayout(ll.ret_layout); + + if (ret_layout_data.tag == .box_of_zst) { + _ = try self.emitValueLocal(GuardedList.at(args, 0)); + const reg = try self.allocTempGeneral(); + try self.codegen.emitLoadImm(reg, 0); + return .{ .general_reg = reg }; + } + + const box_abi = ls.builtinBoxAbi(ll.ret_layout); + const elem_size: u32 = box_abi.elem_size; + if (elem_size == 0) { + _ = try self.emitValueLocal(GuardedList.at(args, 0)); + const reg = try self.allocTempGeneral(); + try self.codegen.emitLoadImm(reg, 0); + return .{ .general_reg = reg }; + } + + const box_loc = try self.emitValueLocal(GuardedList.at(args, 0)); + const box_reg = try self.ensureInGeneralReg(box_loc); + defer self.codegen.freeGeneral(box_reg); + + const elem_incref_reg = if (box_abi.contains_refcounted) + if (box_abi.elem_layout_idx) |idx| try self.emitBuiltinInternalOptionalRcHelperAddress(.incref, idx) else null + else + null; + defer if (elem_incref_reg) |reg| self.codegen.freeGeneral(reg); + + const elem_decref_reg = if (box_abi.contains_refcounted) + if (box_abi.elem_layout_idx) |idx| try self.emitBuiltinInternalOptionalRcHelperAddress(.decref, idx) else null + else + null; + defer if (elem_decref_reg) |reg| self.codegen.freeGeneral(reg); + + const roc_ops_reg = self.roc_ops_reg orelse unreachable; + var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); + try builder.addRegArg(box_reg); + try builder.addImmArg(@intCast(elem_size)); + try builder.addImmArg(@intCast(box_abi.elem_alignment)); + try builder.addImmArg(if (elem_incref_reg != null) 1 else 0); + if (elem_incref_reg) |reg| try builder.addRegArg(reg) else try builder.addImmArg(0); + if (elem_decref_reg) |reg| try builder.addRegArg(reg) else try builder.addImmArg(0); + try builder.addImmArg(updateModeImmForArg0(ll.unique_args)); + try builder.addRegArg(roc_ops_reg); + try self.callBuiltin(&builder, @intFromPtr(&dev_wrappers.roc_builtins_box_prepare_update), .box_prepare_update); + const result_reg = try self.allocTempGeneral(); + try self.codegen.emit.movRegReg(.w64, result_reg, ret_reg_0); + return .{ .general_reg = result_reg }; + }, .erased_capture_load => { const elem_layout_idx = ll.ret_layout; const elem_layout_data = self.layout_store.getLayout(elem_layout_idx); @@ -5745,6 +5813,23 @@ pub fn LirCodeGen(comptime target: RocTarget) type { return .{ .immediate_i128 = val }; } + fn generateStaticDataLiteral(self: *Self, id: lir.LIR.StaticDataId, target_layout: layout.Idx) Allocator.Error!ValueLocation { + const runtime_layout = self.runtimeRepresentationLayoutIdx(target_layout); + const size = self.getLayoutSize(runtime_layout); + if (size == 0) return .{ .immediate_i64 = 0 }; + + const slot = self.codegen.allocStackSlot(size); + const src_reg = try self.allocTempGeneral(); + defer self.codegen.freeGeneral(src_reg); + try self.emitStaticDataAddress(src_reg, id); + + const temp_reg = try self.allocTempGeneral(); + defer self.codegen.freeGeneral(temp_reg); + try self.copyChunked(temp_reg, src_reg, 0, frame_ptr, slot, size); + + return self.stackLocationForLayout(runtime_layout, slot); + } + /// Generate code for a local lookup. fn generateLookup(self: *Self, local: LocalId) Allocator.Error!ValueLocation { const layout_idx = self.localLayout(local); @@ -6164,6 +6249,20 @@ pub fn LirCodeGen(comptime target: RocTarget) type { if (assign.payload) |payload| try locals.put(localKey(payload), payload); try stack.append(sa, assign.next); }, + .store_struct => |assign| { + try locals.put(localKey(assign.dest), assign.dest); + const fields = self.store.getLocalSpan(assign.fields); + for (0..fields.len) |index| { + const field = GuardedList.at(fields, index); + try locals.put(localKey(field), field); + } + try stack.append(sa, assign.next); + }, + .store_tag => |assign| { + try locals.put(localKey(assign.dest), assign.dest); + if (assign.payload) |payload| try locals.put(localKey(payload), payload); + try stack.append(sa, assign.next); + }, .set_local => |assign| { try locals.put(localKey(assign.target), assign.target); try locals.put(localKey(assign.value), assign.value); @@ -6327,6 +6426,20 @@ pub fn LirCodeGen(comptime target: RocTarget) type { if (assign.payload) |payload| try locals.put(localKey(payload), payload); try stack.append(sa, assign.next); }, + .store_struct => |assign| { + try locals.put(localKey(assign.dest), assign.dest); + const fields = self.store.getLocalSpan(assign.fields); + for (0..fields.len) |index| { + const field = GuardedList.at(fields, index); + try locals.put(localKey(field), field); + } + try stack.append(sa, assign.next); + }, + .store_tag => |assign| { + try locals.put(localKey(assign.dest), assign.dest); + if (assign.payload) |payload| try locals.put(localKey(payload), payload); + try stack.append(sa, assign.next); + }, .set_local => |assign| { try locals.put(localKey(assign.target), assign.target); try locals.put(localKey(assign.value), assign.value); @@ -12409,6 +12522,13 @@ pub fn LirCodeGen(comptime target: RocTarget) type { } } + fn emitStaticDataAddress(self: *Self, dst_reg: GeneralReg, id: lir.LIR.StaticDataId) Allocator.Error!void { + const symbol_name = try lir.Program.staticDataSymbolName(self.allocator, id); + errdefer self.allocator.free(symbol_name); + try self.static_data_symbol_names.append(self.allocator, symbol_name); + try self.codegen.emitLoadDataAddress(dst_reg, symbol_name); + } + fn emitHotReloadEnterForHostCallable(self: *Self) Allocator.Error!?i32 { if (!self.enable_hot_reload) return null; @@ -15395,6 +15515,33 @@ pub fn LirCodeGen(comptime target: RocTarget) type { } } + fn copyValueToPointer(self: *Self, value_loc: ValueLocation, value_layout: layout.Idx, ptr_local: LocalId) Allocator.Error!void { + const ls = self.layout_store; + const runtime_layout = self.runtimeRepresentationLayoutIdx(value_layout); + const layout_val = ls.getLayout(runtime_layout); + const value_size = ls.layoutSizeAlign(layout_val).size; + + if (value_size == 0) { + _ = try self.emitValueLocal(ptr_local); + return; + } + + const value_offset: i32 = switch (value_loc) { + .stack => |s| s.offset, + .list_stack => |info| info.struct_offset, + .stack_str => |off| off, + .stack_i128 => |off| off, + else => try self.ensureOnStack(value_loc, value_size), + }; + + const ptr_loc = try self.emitValueLocal(ptr_local); + const ptr_reg = try self.ensureInGeneralReg(ptr_loc); + const temp_reg = try self.allocTempGeneral(); + try self.copyChunked(temp_reg, frame_ptr, value_offset, ptr_reg, 0, value_size); + self.codegen.freeGeneral(temp_reg); + self.codegen.freeGeneral(ptr_reg); + } + /// Generate code for a control flow statement // Per-switch state shared across the explicit-stack continuations that // emit a multi-arm switch. Heap-allocated so every continuation that @@ -15513,6 +15660,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { .str_literal => |str_idx| try self.generateStrLiteral(str_idx), .bytes_literal => |bytes_idx| try self.generateBytesLiteral(bytes_idx), .null_ptr => .{ .immediate_i64 = 0 }, + .static_data => |id| try self.generateStaticDataLiteral(id, self.localLayout(assign.target)), .proc_ref => |proc_id| blk: { const proc = self.proc_registry.get(@intFromEnum(proc_id)) orelse unreachable; const reg = try self.allocTempGeneral(); @@ -15605,6 +15753,26 @@ pub fn LirCodeGen(comptime target: RocTarget) type { try work.append(wa, .{ .node = assign.next }); }, + .store_struct => |assign| { + const value_loc = try self.generateStruct(.{ + .fields = assign.fields, + .target_layout = assign.struct_layout, + }); + try self.copyValueToPointer(value_loc, assign.struct_layout, assign.dest); + try work.append(wa, .{ .node = assign.next }); + }, + + .store_tag => |assign| { + const value_loc = try self.generateTag(.{ + .target_layout = assign.tag_layout, + .variant_index = assign.variant_index, + .discriminant = assign.discriminant, + .payload = assign.payload, + }); + try self.copyValueToPointer(value_loc, assign.tag_layout, assign.dest); + try work.append(wa, .{ .node = assign.next }); + }, + .set_local => |assign| { const value_loc = try self.emitValueLocal(assign.value); try self.bindAssignedLocal(assign.target, value_loc); diff --git a/src/backend/dev/ObjectFileCompiler.zig b/src/backend/dev/ObjectFileCompiler.zig index d0afd1e41b6..d26aa56505b 100644 --- a/src/backend/dev/ObjectFileCompiler.zig +++ b/src/backend/dev/ObjectFileCompiler.zig @@ -481,6 +481,7 @@ fn appendStaticDataExport( .is_global = data_export.is_global, .is_function = false, .is_external = false, + .is_hidden = !data_export.is_exported, .section = .rodata, }) catch { return CompilationError.OutOfMemory; diff --git a/src/backend/dev/ObjectWriter.zig b/src/backend/dev/ObjectWriter.zig index 606494aa08a..3e6d23334e0 100644 --- a/src/backend/dev/ObjectWriter.zig +++ b/src/backend/dev/ObjectWriter.zig @@ -75,6 +75,7 @@ pub fn generateObjectFileWithDebug( .size = sym.size, .is_global = sym.is_global or sym.is_external, .is_function = sym.is_function, + .is_hidden = sym.is_hidden, }); // Add relocations for this symbol @@ -223,6 +224,7 @@ pub const Symbol = struct { is_global: bool, is_function: bool, is_external: bool, + is_hidden: bool = false, // Unwind metadata for Windows object files. prologue_size: u32 = 0, stack_alloc: u32 = 0, diff --git a/src/backend/dev/RunImage.zig b/src/backend/dev/RunImage.zig index 52374fe4d3d..fcf6b9aa94a 100644 --- a/src/backend/dev/RunImage.zig +++ b/src/backend/dev/RunImage.zig @@ -9,6 +9,7 @@ const std = @import("std"); const Relocation = @import("Relocation.zig").Relocation; const DataRelocationKind = @import("Relocation.zig").DataRelocationKind; const StaticDataExport = @import("StaticDataExport.zig").StaticDataExport; +const StaticDataRelocation = @import("StaticDataExport.zig").StaticDataRelocation; const Allocator = std.mem.Allocator; @@ -16,7 +17,7 @@ const Allocator = std.mem.Allocator; pub const MAGIC: u32 = 0x56454452; /// Version of the shared-memory dev run image format. -pub const FORMAT_VERSION: u32 = 2; +pub const FORMAT_VERSION: u32 = 3; /// Maximum bytes needed for one host jump stub on the supported dev-shim hosts. pub const max_jump_stub_size = 20; @@ -61,6 +62,7 @@ pub const Header = extern struct { function_stubs: ArrayRef, entrypoints: ArrayRef, relocations: ArrayRef, + data_relocations: ArrayRef, symbol_names: ArrayRef, data: ArrayRef, data_symbols: ArrayRef, @@ -111,6 +113,22 @@ pub const DataSymbol = extern struct { _padding: u32 = 0, }; +/// Pointer relocation from one readonly data symbol to another image symbol. +pub const DataRelocationRecord = extern struct { + data_offset: u64, + symbol: StringRef, + addend: i64, + kind: u8, + _padding: [7]u8 = [_]u8{0} ** 7, + + pub fn relocationKind(self: DataRelocationRecord) ImageError!RelocationKind { + return switch (self.kind) { + @intFromEnum(RelocationKind.linked_data_abs64) => .linked_data_abs64, + else => error.InvalidDevRunImage, + }; + } +}; + /// Entrypoint metadata provided by codegen before the image is serialized. pub const EntrypointInput = struct { ordinal: u32, @@ -124,6 +142,7 @@ pub const ProgramView = struct { function_stubs: []u8, entrypoints: []const Entrypoint, relocations: []const RelocationRecord, + data_relocations: []const DataRelocationRecord, symbol_names: []const u8, data: []u8, data_symbols: []const DataSymbol, @@ -159,6 +178,8 @@ pub fn writeToSharedMemory( var relocation_records = std.ArrayList(RelocationRecord).empty; defer relocation_records.deinit(scratch); + var data_relocation_records = std.ArrayList(DataRelocationRecord).empty; + defer data_relocation_records.deinit(scratch); for (relocations) |relocation| { switch (relocation) { @@ -191,7 +212,6 @@ pub fn writeToSharedMemory( var max_data_alignment: usize = 1; for (data_exports) |data_export| { - if (data_export.relocations.len != 0) return error.UnsupportedStaticDataRelocation; const alignment = if (data_export.alignment == 0) 1 else data_export.alignment; if (!std.math.isPowerOfTwo(alignment)) return error.InvalidStaticDataAlignment; max_data_alignment = @max(max_data_alignment, alignment); @@ -211,9 +231,23 @@ pub fn writeToSharedMemory( .symbol_offset = data_export.symbol_offset, .alignment = alignment, }); + + for (data_export.relocations) |relocation| { + if (relocation.offset > std.math.maxInt(usize)) return error.InvalidDevRunImage; + const relocation_offset: usize = @intCast(relocation.offset); + if (relocation_offset > data_export.bytes.len or @sizeOf(usize) > data_export.bytes.len - relocation_offset) { + return error.InvalidDevRunImage; + } + try data_relocation_records.append(scratch, .{ + .data_offset = @intCast(data_offset + relocation_offset), + .symbol = try appendStringRef(scratch, &symbol_names, relocation.target_symbol_name), + .addend = relocation.addend, + .kind = @intFromEnum(RelocationKind.linked_data_abs64), + }); + } } - const function_stub_count = try countReservedFunctionStubs(scratch, relocations, &data_symbol_names); + const function_stub_count = try countReservedFunctionStubs(scratch, relocations, data_exports, &data_symbol_names); const function_stub_len = try mulNoOverflow(function_stub_count, max_jump_stub_size); const header = try image_allocator.create(Header); @@ -230,6 +264,9 @@ pub fn writeToSharedMemory( const relocation_copy = try image_allocator.alloc(RelocationRecord, relocation_records.items.len); @memcpy(relocation_copy, relocation_records.items); + const data_relocation_copy = try image_allocator.alloc(DataRelocationRecord, data_relocation_records.items.len); + @memcpy(data_relocation_copy, data_relocation_records.items); + const symbol_names_copy = try image_allocator.alloc(u8, symbol_names.items.len); @memcpy(symbol_names_copy, symbol_names.items); @@ -264,6 +301,7 @@ pub fn writeToSharedMemory( bytesOf(header), bytesOfSlice(entrypoints), bytesOfSlice(relocation_copy), + bytesOfSlice(data_relocation_copy), symbol_names_copy, bytesOfSlice(data_symbols_copy), executable, @@ -280,6 +318,7 @@ pub fn writeToSharedMemory( .function_stubs = try arrayRef(base_ptr, function_stubs), .entrypoints = try arrayRef(base_ptr, bytesOfSlice(entrypoints)), .relocations = try arrayRef(base_ptr, bytesOfSlice(relocation_copy)), + .data_relocations = try arrayRef(base_ptr, bytesOfSlice(data_relocation_copy)), .symbol_names = try arrayRef(base_ptr, symbol_names_copy), .data = try arrayRef(base_ptr, data_copy), .data_symbols = try arrayRef(base_ptr, bytesOfSlice(data_symbols_copy)), @@ -329,14 +368,23 @@ pub fn requiredCapacityFromOffset( var data_len: usize = 0; var max_data_alignment: usize = 1; + var data_relocation_count: usize = 0; for (data_exports) |data_export| { - if (data_export.relocations.len != 0) return error.UnsupportedStaticDataRelocation; const alignment = if (data_export.alignment == 0) 1 else data_export.alignment; if (!std.math.isPowerOfTwo(alignment)) return error.InvalidStaticDataAlignment; max_data_alignment = @max(max_data_alignment, alignment); data_len = std.mem.alignForward(usize, data_len, alignment); data_len = try addNoOverflow(data_len, data_export.bytes.len); symbol_names_len = try addNoOverflow(symbol_names_len, data_export.symbol_name.len); + data_relocation_count = try addNoOverflow(data_relocation_count, data_export.relocations.len); + for (data_export.relocations) |relocation| { + if (relocation.offset > std.math.maxInt(usize)) return error.InvalidDevRunImage; + const relocation_offset: usize = @intCast(relocation.offset); + if (relocation_offset > data_export.bytes.len or @sizeOf(usize) > data_export.bytes.len - relocation_offset) { + return error.InvalidDevRunImage; + } + symbol_names_len = try addNoOverflow(symbol_names_len, relocation.target_symbol_name.len); + } } const function_stub_count = try countReservedFunctionStubsNoAlloc(relocations, data_exports); @@ -346,6 +394,7 @@ pub fn requiredCapacityFromOffset( capacity = try addAllocationCapacity(capacity, @alignOf(Header), @sizeOf(Header)); capacity = try addAllocationCapacity(capacity, @alignOf(Entrypoint), try mulNoOverflow(entrypoint_inputs.len, @sizeOf(Entrypoint))); capacity = try addAllocationCapacity(capacity, @alignOf(RelocationRecord), try mulNoOverflow(relocation_count, @sizeOf(RelocationRecord))); + capacity = try addAllocationCapacity(capacity, @alignOf(DataRelocationRecord), try mulNoOverflow(data_relocation_count, @sizeOf(DataRelocationRecord))); capacity = try addAllocationCapacity(capacity, @alignOf(u8), symbol_names_len); capacity = try addAllocationCapacity(capacity, @alignOf(DataSymbol), try mulNoOverflow(data_exports.len, @sizeOf(DataSymbol))); capacity = try addAllocationCapacity(capacity, page_size, code.len); @@ -371,6 +420,7 @@ pub fn viewMappedImage(header: *const Header, base_ptr: [*]align(1) u8, mapped_s .function_stubs = try bytesFromRef(base_ptr, image_size, header.function_stubs), .entrypoints = try sliceFromRef(Entrypoint, base_ptr, image_size, header.entrypoints), .relocations = try sliceFromRef(RelocationRecord, base_ptr, image_size, header.relocations), + .data_relocations = try sliceFromRef(DataRelocationRecord, base_ptr, image_size, header.data_relocations), .symbol_names = try bytesFromRef(base_ptr, image_size, header.symbol_names), .data = try bytesFromRef(base_ptr, image_size, header.data), .data_symbols = try sliceFromRef(DataSymbol, base_ptr, image_size, header.data_symbols), @@ -399,6 +449,7 @@ fn appendStringRef(scratch: Allocator, symbol_names: *std.ArrayList(u8), name: [ fn countReservedFunctionStubs( scratch: Allocator, relocations: []const Relocation, + data_exports: []const StaticDataExport, data_symbol_names: *const std.StringHashMapUnmanaged(void), ) WriteError!usize { var stub_names = std.StringHashMapUnmanaged(void){}; @@ -412,6 +463,12 @@ fn countReservedFunctionStubs( }; try stub_names.put(scratch, name, {}); } + for (data_exports) |data_export| { + for (data_export.relocations) |relocation| { + if (data_symbol_names.contains(relocation.target_symbol_name)) continue; + try stub_names.put(scratch, relocation.target_symbol_name, {}); + } + } return stub_names.count(); } @@ -439,9 +496,44 @@ fn countReservedFunctionStubsNoAlloc( count = try addNoOverflow(count, 1); } } + for (data_exports) |data_export| { + for (data_export.relocations) |relocation| { + if (dataExportNamesContain(data_exports, relocation.target_symbol_name)) continue; + if (relocationNameSeenInCode(relocations, data_exports, relocation.target_symbol_name)) continue; + if (relocationNameSeenInDataBefore(data_exports, data_export, relocation.target_symbol_name)) continue; + count = try addNoOverflow(count, 1); + } + } return count; } +fn relocationNameSeenInCode(relocations: []const Relocation, data_exports: []const StaticDataExport, name: []const u8) bool { + for (relocations) |relocation| { + const previous_name = switch (relocation) { + .linked_function => |function| function.name, + .linked_data => |data| if (dataExportNamesContain(data_exports, data.name)) continue else data.name, + .local_data, .jmp_to_return => continue, + }; + if (std.mem.eql(u8, previous_name, name)) return true; + } + return false; +} + +fn relocationNameSeenInDataBefore( + data_exports: []const StaticDataExport, + current: StaticDataExport, + name: []const u8, +) bool { + for (data_exports) |data_export| { + if (std.mem.eql(u8, data_export.symbol_name, current.symbol_name)) return false; + for (data_export.relocations) |relocation| { + if (dataExportNamesContain(data_exports, relocation.target_symbol_name)) continue; + if (std.mem.eql(u8, relocation.target_symbol_name, name)) return true; + } + } + return false; +} + fn dataExportNamesContain(data_exports: []const StaticDataExport, name: []const u8) bool { for (data_exports) |data_export| { if (std.mem.eql(u8, data_export.symbol_name, name)) return true; @@ -546,13 +638,31 @@ test "writeToSharedMemory serializes only executable image sections" { .{ .linked_function = .{ .offset = 1, .name = "roc_alloc" } }, .{ .linked_data = .{ .offset = 2, .name = "roc__answer", .kind = .rel32 } }, }; - const data_bytes = [_]u8{ 1, 2, 3, 4 }; + const data_bytes = [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; + const target_data_bytes = [_]u8{42}; + const data_relocations = [_]StaticDataRelocation{ + .{ + .offset = 0, + .target_symbol_name = "roc__target", + .addend = 4, + }, + .{ + .offset = @sizeOf(usize), + .target_symbol_name = "roc_external", + }, + }; const data_exports = [_]StaticDataExport{ .{ .symbol_name = "roc__static", .bytes = &data_bytes, .symbol_offset = 1, .alignment = 8, + .relocations = &data_relocations, + }, + .{ + .symbol_name = "roc__target", + .bytes = &target_data_bytes, + .alignment = 1, }, }; const capacity = try requiredCapacity(page_size, &code, &entrypoint_inputs, &relocations, &data_exports); @@ -582,7 +692,7 @@ test "writeToSharedMemory serializes only executable image sections" { const view = try viewMappedImage(header, image_bytes.ptr, @intCast(header.image_size)); try std.testing.expectEqual(page_size, view.page_size); - try std.testing.expectEqual(@as(usize, 2 * max_jump_stub_size), view.function_stubs.len); + try std.testing.expectEqual(@as(usize, 3 * max_jump_stub_size), view.function_stubs.len); try std.testing.expectEqual(@as(usize, 0), @intFromPtr(view.executable.ptr) % page_size); try std.testing.expectEqualSlices(u8, &code, view.code); try std.testing.expectEqual(@as(usize, entrypoint_inputs.len), view.entrypoints.len); @@ -599,11 +709,25 @@ test "writeToSharedMemory serializes only executable image sections" { try std.testing.expectEqual(@as(u64, 2), view.relocations[1].code_offset); try std.testing.expectEqualStrings("roc__answer", try view.symbolName(view.relocations[1].symbol)); - try std.testing.expectEqualSlices(u8, &data_bytes, view.data); + try std.testing.expectEqualSlices(u8, &data_bytes, view.data[0..data_bytes.len]); + try std.testing.expectEqualSlices(u8, &target_data_bytes, view.data[data_bytes.len..][0..target_data_bytes.len]); try std.testing.expectEqual(@as(usize, data_exports.len), view.data_symbols.len); try std.testing.expectEqualStrings("roc__static", try view.dataSymbolName(view.data_symbols[0])); try std.testing.expectEqual(@as(u64, 0), view.data_symbols[0].data_offset); try std.testing.expectEqual(@as(u64, data_bytes.len), view.data_symbols[0].len); try std.testing.expectEqual(@as(u64, 1), view.data_symbols[0].symbol_offset); try std.testing.expectEqual(@as(u32, 8), view.data_symbols[0].alignment); + try std.testing.expectEqualStrings("roc__target", try view.dataSymbolName(view.data_symbols[1])); + try std.testing.expectEqual(@as(u64, data_bytes.len), view.data_symbols[1].data_offset); + try std.testing.expectEqual(@as(u64, target_data_bytes.len), view.data_symbols[1].len); + + try std.testing.expectEqual(@as(usize, data_relocations.len), view.data_relocations.len); + try std.testing.expectEqual(RelocationKind.linked_data_abs64, try view.data_relocations[0].relocationKind()); + try std.testing.expectEqual(@as(u64, 0), view.data_relocations[0].data_offset); + try std.testing.expectEqual(@as(i64, 4), view.data_relocations[0].addend); + try std.testing.expectEqualStrings("roc__target", try view.symbolName(view.data_relocations[0].symbol)); + try std.testing.expectEqual(RelocationKind.linked_data_abs64, try view.data_relocations[1].relocationKind()); + try std.testing.expectEqual(@as(u64, @sizeOf(usize)), view.data_relocations[1].data_offset); + try std.testing.expectEqual(@as(i64, 0), view.data_relocations[1].addend); + try std.testing.expectEqualStrings("roc_external", try view.symbolName(view.data_relocations[1].symbol)); } diff --git a/src/backend/dev/StaticDataExport.zig b/src/backend/dev/StaticDataExport.zig index b5d9c9fe2ae..01f1e7fb6e1 100644 --- a/src/backend/dev/StaticDataExport.zig +++ b/src/backend/dev/StaticDataExport.zig @@ -13,8 +13,18 @@ pub const StaticDataExport = struct { symbol_offset: u32 = 0, /// Required alignment of the symbol inside the readonly section. alignment: u32, - /// Whether the symbol should be visible to the host linker. + /// Whether the object-file symbol should have global linker binding. + /// + /// Internal static constants referenced from a separately compiled LLVM + /// object need this so the linker can resolve `roc__static_value_*` + /// references across object files. is_global: bool = true, + /// Whether this symbol is part of the host-visible ABI. + /// + /// Exported data symbols are rooted under section garbage collection and + /// included in shared-library/module export lists. Internal static + /// constants can be linker-global without being host-exported. + is_exported: bool = true, /// Pointer relocations from this symbol's bytes to other symbols. relocations: []const StaticDataRelocation = &.{}, }; diff --git a/src/backend/dev/StaticStringData.zig b/src/backend/dev/StaticStringData.zig index a0097d6cbd5..c714e54f13d 100644 --- a/src/backend/dev/StaticStringData.zig +++ b/src/backend/dev/StaticStringData.zig @@ -105,6 +105,7 @@ pub fn build(allocator: Allocator, store: *const lir.LirStore, target: RocTarget .symbol_offset = data_offset, .alignment = word_size, .is_global = false, + .is_exported = false, .relocations = relocations, }); symbol_owned = false; diff --git a/src/backend/dev/object/elf.zig b/src/backend/dev/object/elf.zig index 07bf6214349..9df8350c805 100644 --- a/src/backend/dev/object/elf.zig +++ b/src/backend/dev/object/elf.zig @@ -48,6 +48,10 @@ const ELF = struct { const STT_FUNC = 2; const STT_SECTION = 3; + // Symbol visibility + const STV_DEFAULT = 0; + const STV_HIDDEN = 2; + // Special section indices const SHN_UNDEF = 0; @@ -133,6 +137,7 @@ pub const Symbol = struct { size: u64, is_global: bool, is_function: bool, + is_hidden: bool = false, }; /// Section types @@ -458,7 +463,7 @@ pub const ElfWriter = struct { const elf_sym = Elf64_Sym{ .st_name = name_offset, .st_info = st_info, - .st_other = 0, + .st_other = if (sym.is_hidden) ELF.STV_HIDDEN else ELF.STV_DEFAULT, .st_shndx = st_shndx, .st_value = sym.offset, .st_size = sym.size, diff --git a/src/backend/dev/object_reader.zig b/src/backend/dev/object_reader.zig index cfb4ae9ac56..b48cce29f6e 100644 --- a/src/backend/dev/object_reader.zig +++ b/src/backend/dev/object_reader.zig @@ -878,6 +878,7 @@ fn resolveBuiltinWrapper(name: []const u8) ?usize { .{ .name = "roc_builtins_list_decref_with_single_thread", .addr = @intFromPtr(&dev_wrappers.roc_builtins_list_decref_with_single_thread) }, .{ .name = "roc_builtins_list_free_flat_list", .addr = @intFromPtr(&dev_wrappers.roc_builtins_list_free_flat_list) }, .{ .name = "roc_builtins_list_free_with", .addr = @intFromPtr(&dev_wrappers.roc_builtins_list_free_with) }, + .{ .name = "roc_builtins_box_prepare_update", .addr = @intFromPtr(&dev_wrappers.roc_builtins_box_prepare_update) }, .{ .name = "roc_builtins_box_decref_with", .addr = @intFromPtr(&dev_wrappers.roc_builtins_box_decref_with) }, .{ .name = "roc_builtins_box_decref_with_single_thread", .addr = @intFromPtr(&dev_wrappers.roc_builtins_box_decref_with_single_thread) }, .{ .name = "roc_builtins_box_free_with", .addr = @intFromPtr(&dev_wrappers.roc_builtins_box_free_with) }, diff --git a/src/backend/llvm/MonoLlvmCodeGen.zig b/src/backend/llvm/MonoLlvmCodeGen.zig index 098868ab186..66b7e4539ca 100644 --- a/src/backend/llvm/MonoLlvmCodeGen.zig +++ b/src/backend/llvm/MonoLlvmCodeGen.zig @@ -164,6 +164,7 @@ pub const MonoLlvmCodeGen = struct { proc_registry: std.AutoHashMap(u32, LlvmBuilder.Function.Index), builtin_functions: std.StringHashMap(LlvmBuilder.Function.Index), static_bytes: std.StringHashMap(LlvmBuilder.Value), + static_data_globals: std.AutoHashMap(u32, LlvmBuilder.Value), runtime_error_func: ?LlvmBuilder.Function.Index = null, rc_helpers: std.AutoHashMap(u64, RcHelperEntry), join_points: std.AutoHashMap(u32, JoinInfo), @@ -306,6 +307,7 @@ pub const MonoLlvmCodeGen = struct { .proc_registry = std.AutoHashMap(u32, LlvmBuilder.Function.Index).init(allocator), .builtin_functions = std.StringHashMap(LlvmBuilder.Function.Index).init(allocator), .static_bytes = std.StringHashMap(LlvmBuilder.Value).init(allocator), + .static_data_globals = std.AutoHashMap(u32, LlvmBuilder.Value).init(allocator), .rc_helpers = std.AutoHashMap(u64, RcHelperEntry).init(allocator), .join_points = std.AutoHashMap(u32, JoinInfo).init(allocator), .compiled_joins = std.AutoHashMap(u32, void).init(allocator), @@ -344,6 +346,7 @@ pub const MonoLlvmCodeGen = struct { self.builtin_functions.deinit(); self.clearStaticBytes(); self.static_bytes.deinit(); + self.static_data_globals.deinit(); self.rc_helpers.deinit(); self.join_points.deinit(); self.compiled_joins.deinit(); @@ -358,6 +361,7 @@ pub const MonoLlvmCodeGen = struct { self.proc_registry.clearRetainingCapacity(); self.builtin_functions.clearRetainingCapacity(); self.clearStaticBytes(); + self.static_data_globals.clearRetainingCapacity(); self.rc_helpers.clearRetainingCapacity(); self.join_points.clearRetainingCapacity(); self.compiled_joins.clearRetainingCapacity(); @@ -557,6 +561,48 @@ pub const MonoLlvmCodeGen = struct { builder.finishModuleAsm(&aw) catch return error.OutOfMemory; } + fn emitDefaultBuildStartModuleAsm(self: *MonoLlvmCodeGen, builder: *LlvmBuilder, main_symbol: []const u8) Error!void { + if (self.target.os.tag != .linux) return error.CompilationFailed; + + var aw: std.Io.Writer.Allocating = .init(self.allocator); + defer aw.deinit(); + const w = &aw.writer; + + switch (self.target.cpu.arch) { + .x86_64 => w.print( + \\.text + \\.globl _start + \\.type _start,@function + \\_start: + \\ and $-16, %rsp + \\ call roc_default_runtime_init + \\ call {s} + \\ mov %rax, %rdi + \\ mov $60, %rax + \\ syscall + \\ ud2 + \\.size _start, .-_start + \\ + , .{main_symbol}) catch return error.OutOfMemory, + .aarch64 => w.print( + \\.text + \\.globl _start + \\.type _start,%function + \\_start: + \\ bl roc_default_runtime_init + \\ bl {s} + \\ mov x8, #94 + \\ svc #0 + \\ brk #0 + \\.size _start, .-_start + \\ + , .{main_symbol}) catch return error.OutOfMemory, + else => return error.CompilationFailed, + } + + builder.finishModuleAsm(&aw) catch return error.OutOfMemory; + } + pub fn generateEntrypointModule( self: *MonoLlvmCodeGen, module_name: []const u8, @@ -1452,10 +1498,10 @@ pub const MonoLlvmCodeGen = struct { .registers => |pieces| { ret_pieces = pieces; if (pieces.len == 1) { - ret_ty = try pieceLlvmType(builder, pieces[0]); + ret_ty = try self.cAbiPieceLlvmType(builder, pieces[0]); } else { const field_types = try arena.alloc(LlvmBuilder.Type, pieces.len); - for (pieces, field_types) |piece, *t| t.* = try pieceLlvmType(builder, piece); + for (pieces, field_types) |piece, *t| t.* = try self.cAbiPieceLlvmType(builder, piece); ret_ty = builder.structType(.normal, field_types) catch return error.OutOfMemory; } }, @@ -1473,7 +1519,7 @@ pub const MonoLlvmCodeGen = struct { }, .registers => |pieces| { for (pieces) |piece| { - try param_types.append(self.allocator, try pieceLlvmType(builder, piece)); + try param_types.append(self.allocator, try self.cAbiPieceLlvmType(builder, piece)); } }, } @@ -1538,7 +1584,8 @@ pub const MonoLlvmCodeGen = struct { const val = wip.arg(param_cursor); param_cursor += 1; const dst = try self.offsetPtr(args_buf, offset + piece.offset); - _ = wip.store(.normal, val, dst, arg_align) catch return error.OutOfMemory; + const store_val = try self.coerceScalar(val, try pieceLlvmType(builder, piece), self.cAbiPieceIsSigned(arg_layout)); + _ = wip.store(.normal, store_val, dst, arg_align) catch return error.OutOfMemory; } }, } @@ -1567,15 +1614,19 @@ pub const MonoLlvmCodeGen = struct { const ret_align = self.alignmentForLayout(ret_layout); if (ret_pieces.len == 1) { const src = try self.offsetPtr(ret_slot, ret_pieces[0].offset); - const val = wip.load(.normal, ret_ty, src, ret_align, "") catch return error.OutOfMemory; - _ = wip.ret(val) catch return error.OutOfMemory; + const piece_ty = try pieceLlvmType(builder, ret_pieces[0]); + const val = wip.load(.normal, piece_ty, src, ret_align, "") catch return error.OutOfMemory; + const ret_val = try self.coerceScalar(val, ret_ty, self.cAbiPieceIsSigned(ret_layout)); + _ = wip.ret(ret_val) catch return error.OutOfMemory; } else { var agg = builder.poisonValue(ret_ty) catch return error.OutOfMemory; for (ret_pieces, 0..) |piece, i| { const piece_ty = try pieceLlvmType(builder, piece); const src = try self.offsetPtr(ret_slot, piece.offset); const val = wip.load(.normal, piece_ty, src, ret_align, "") catch return error.OutOfMemory; - agg = wip.insertValue(agg, val, &.{@intCast(i)}, "") catch return error.OutOfMemory; + const field_ty = try self.cAbiPieceLlvmType(builder, piece); + const field = try self.coerceScalar(val, field_ty, self.cAbiPieceIsSigned(ret_layout)); + agg = wip.insertValue(agg, field, &.{@intCast(i)}, "") catch return error.OutOfMemory; } _ = wip.ret(agg) catch return error.OutOfMemory; } @@ -1647,6 +1698,8 @@ pub const MonoLlvmCodeGen = struct { arg_layouts: []const layout.Idx, ret_layout: layout.Idx, ) Error!void { + if (!std.mem.eql(u8, symbol_name, "_start")) return error.CompilationFailed; + switch (self.target.cpu.arch) { .x86_64, .aarch64 => {}, else => return error.CompilationFailed, @@ -1655,8 +1708,9 @@ pub const MonoLlvmCodeGen = struct { const builder = self.builder orelse return error.CompilationFailed; const proc_fn = self.proc_registry.get(@intFromEnum(entry_proc)) orelse return error.CompilationFailed; - const wrapper_ty = builder.fnType(.void, &.{}, .normal) catch return error.OutOfMemory; - const wrapper_name = try self.exportedFunctionName(builder, symbol_name); + const main_symbol = "roc_default_start_main"; + const wrapper_ty = builder.fnType(self.ptrSizedIntType(), &.{}, .normal) catch return error.OutOfMemory; + const wrapper_name = builder.strtabString(main_symbol) catch return error.OutOfMemory; const wrapper = builder.addFunction(wrapper_ty, wrapper_name, .default) catch return error.OutOfMemory; wrapper.setLinkage(.external, builder); var attrs_wip: LlvmBuilder.FunctionAttributes.Wip = .{}; @@ -1675,20 +1729,16 @@ pub const MonoLlvmCodeGen = struct { const entry = wip.block(0, "entry") catch return error.OutOfMemory; wip.cursor = .{ .block = entry }; - if (self.enable_default_platform_runtime) { - const init_ty = builder.fnType(.void, &.{}, .normal) catch return error.OutOfMemory; - const init_fn = try self.declareExternSymbol("roc_default_runtime_init", init_ty); - _ = wip.call(.normal, .ccc, .none, init_ty, init_fn.toValue(builder), &.{}, "") catch return error.OutOfMemory; - } - const ret_slot = try self.allocArgBuffer(&.{ret_layout}, false); const args_buf = try self.allocArgBuffer(arg_layouts, true); _ = try self.callFunctionIndex(proc_fn, &.{ ret_slot, args_buf }, false); const exit_code_raw = try self.loadScalar(ret_slot, ret_layout); const exit_code = try self.coerceScalar(exit_code_raw, self.ptrSizedIntType(), false); - try self.emitLinuxExitSyscall(exit_code); + _ = wip.ret(exit_code) catch return error.OutOfMemory; try self.finishCurrentWipFunction(); + + try self.emitDefaultBuildStartModuleAsm(builder, main_symbol); } fn createBuilder(self: *MonoLlvmCodeGen, name: []const u8) Error!LlvmBuilder { @@ -1997,6 +2047,8 @@ pub const MonoLlvmCodeGen = struct { .assign_list, .assign_struct, .assign_tag, + .store_struct, + .store_tag, .set_local, .debug, .expect, @@ -2172,7 +2224,7 @@ pub const MonoLlvmCodeGen = struct { try work.append(wa, .{ .node = assign.next }); }, .assign_packed_erased_fn => |assign| { - try self.emitPackedErasedFn(assign.target, assign.proc, assign.capture, assign.capture_layout, assign.on_drop); + try self.emitPackedErasedFn(assign.target, assign.proc, assign.capture, assign.capture_layout, assign.on_drop, assign.reuse, assign.reuse_unique); try work.append(wa, .{ .node = assign.next }); }, .assign_low_level => |assign| { @@ -2191,6 +2243,14 @@ pub const MonoLlvmCodeGen = struct { try self.emitTagLiteral(assign.target, assign.discriminant, assign.payload); try work.append(wa, .{ .node = assign.next }); }, + .store_struct => |assign| { + try self.emitStoreStruct(assign.dest, assign.struct_layout, assign.fields); + try work.append(wa, .{ .node = assign.next }); + }, + .store_tag => |assign| { + try self.emitStoreTag(assign.dest, assign.tag_layout, assign.discriminant, assign.payload); + try work.append(wa, .{ .node = assign.next }); + }, .set_local => |assign| { try self.copyLocal(assign.target, assign.value); try work.append(wa, .{ .node = assign.next }); @@ -2333,6 +2393,7 @@ pub const MonoLlvmCodeGen = struct { .f32_literal => |lit| try self.storeFloatLiteral(slot_v.ptr, .f32, lit), .dec_literal => |lit| try self.storeI128Literal(slot_v.ptr, .dec, lit), .str_literal => |str_idx| try self.emitStrLiteral(slot_v.ptr, str_idx), + .static_data => |id| try self.emitStaticDataLiteral(slot_v, id), .bytes_literal => |bytes_idx| try self.emitBytesLiteral(slot_v.ptr, bytes_idx), .null_ptr => { if (slot_v.size > 0) try self.zeroBytes(slot_v.ptr, slot_v.size); @@ -2423,16 +2484,57 @@ pub const MonoLlvmCodeGen = struct { capture: ?LocalId, capture_layout: ?layout.Idx, on_drop: lir.LIR.ErasedCallableOnDrop, + reuse: ?LocalId, + reuse_unique: bool, ) Error!void { try self.prepareLocalWrite(target); if (capture) |capture_local| try self.materializeLocalIfDeferred(capture_local); + if (reuse) |reuse_local| try self.materializeLocalIfDeferred(reuse_local); const builder = self.builder orelse return error.CompilationFailed; + const ptr_ty = try self.ptrType(); const capture_size = if (capture_layout) |idx| self.layoutByteSize(idx) else 0; + const proc_fn = self.proc_registry.get(@intFromEnum(proc_id)) orelse return error.CompilationFailed; + const null_ptr = builder.nullValue(ptr_ty) catch return error.OutOfMemory; + const on_drop_value = switch (on_drop) { + .none => null_ptr, + .rc_helper => |helper_key| blk: { + // `on_drop` is selected here at closure creation, which is not + // an RC statement and makes no thread-confinement claim, so it + // is always the atomic helper (atomic is always sound). + break :blk if (try self.declareRcHelper(helper_key, .atomic)) |helper_fn| + helper_fn.toValue(builder) + else + null_ptr; + }, + .interpreter_context_drop => return error.CompilationFailed, + }; + + if (reuse) |reuse_local| { + const update_mode = if (reuse_unique) builtins.utils.UpdateMode.InPlace else builtins.utils.UpdateMode.Immutable; + const capture_src = if (capture != null and capture_size > 0) self.slot(capture.?).ptr else null_ptr; + const data_ptr = try self.callBuiltin( + "roc_builtins_erased_callable_repack", + ptr_ty, + &.{ ptr_ty, ptr_ty, ptr_ty, ptr_ty, self.ptrSizedIntType(), .i8, ptr_ty }, + &.{ + try self.loadPointer(self.slot(reuse_local).ptr), + proc_fn.toValue(builder), + on_drop_value, + capture_src, + builder.intValue(self.ptrSizedIntType(), capture_size) catch return error.OutOfMemory, + builder.intValue(.i8, @intFromEnum(update_mode)) catch return error.OutOfMemory, + self.rocOps(), + }, + ); + try self.storePointer(self.slot(target).ptr, data_ptr); + return; + } + const payload_size: u64 = builtins.erased_callable.payloadSize(capture_size); const data_ptr = try self.callBuiltin( "roc_builtins_allocate_with_refcount", - try self.ptrType(), - &.{ self.ptrSizedIntType(), .i32, .i1, try self.ptrType() }, + ptr_ty, + &.{ self.ptrSizedIntType(), .i32, .i1, ptr_ty }, &.{ builder.intValue(self.ptrSizedIntType(), payload_size) catch return error.OutOfMemory, builder.intValue(.i32, builtins.erased_callable.payload_alignment) catch return error.OutOfMemory, @@ -2440,23 +2542,8 @@ pub const MonoLlvmCodeGen = struct { self.rocOps(), }, ); - const proc_fn = self.proc_registry.get(@intFromEnum(proc_id)) orelse return error.CompilationFailed; try self.storePointer(data_ptr, proc_fn.toValue(builder)); - const on_drop_ptr = try self.offsetPtr(data_ptr, self.targetWordSize()); - switch (on_drop) { - .none => try self.storePointer(on_drop_ptr, builder.nullValue(try self.ptrType()) catch return error.OutOfMemory), - .rc_helper => |helper_key| { - // `on_drop` is selected here at closure creation, which is not - // an RC statement and makes no thread-confinement claim, so it - // is always the atomic helper (atomic is always sound). - const helper_value = if (try self.declareRcHelper(helper_key, .atomic)) |helper_fn| - helper_fn.toValue(builder) - else - builder.nullValue(try self.ptrType()) catch return error.OutOfMemory; - try self.storePointer(on_drop_ptr, helper_value); - }, - .interpreter_context_drop => return error.CompilationFailed, - } + try self.storePointer(try self.offsetPtr(data_ptr, self.targetWordSize()), on_drop_value); if (capture) |capture_local| { if (capture_size > 0) { const capture_dst = try self.offsetPtr(data_ptr, builtins.erased_callable.capture_offset); @@ -2537,6 +2624,44 @@ pub const MonoLlvmCodeGen = struct { } } + fn emitStoreStruct(self: *MonoLlvmCodeGen, dest: LocalId, struct_layout: layout.Idx, fields: LocalSpan) Error!void { + const field_locals = self.store.getLocalSpan(fields); + try self.materializeLocalSpanIfDeferred(field_locals); + + const base_layout = self.layoutValue(struct_layout); + if (base_layout.tag != .struct_) return; + + const dst = try self.loadPointer(self.slot(dest).ptr); + try self.zeroBytes(dst, self.layoutByteSize(struct_layout)); + for (0..field_locals.len) |i| { + const field_local = GuardedList.at(field_locals, i); + const field_layout = self.layouts().getStructFieldLayoutByOriginalIndex(base_layout.getStruct().idx, @intCast(i)); + const field_size = self.layoutByteSize(field_layout); + if (field_size == 0) continue; + const offset = self.layouts().getStructFieldOffsetByOriginalIndex(base_layout.getStruct().idx, @intCast(i)); + const field_dst = try self.offsetPtr(dst, offset); + try self.copyBytes(field_dst, self.slot(field_local).ptr, field_size, self.alignmentForLayout(field_layout)); + } + } + + fn emitStoreTag(self: *MonoLlvmCodeGen, dest: LocalId, tag_layout: layout.Idx, discriminant: u16, payload: ?LocalId) Error!void { + if (payload) |payload_local| try self.materializeLocalIfDeferred(payload_local); + + const dst = try self.loadPointer(self.slot(dest).ptr); + const layout_size = self.layoutByteSize(tag_layout); + if (layout_size == 0) return; + + try self.zeroBytes(dst, layout_size); + if (payload) |payload_local| { + const payload_layout = self.tagPayloadLayout(tag_layout, discriminant); + const payload_size = self.layoutByteSize(payload_layout); + if (payload_size > 0) { + try self.copyBytes(dst, self.slot(payload_local).ptr, payload_size, self.alignmentForLayout(payload_layout)); + } + } + try self.writeTagDiscriminant(dst, tag_layout, discriminant); + } + fn allocAggregateTarget(self: *MonoLlvmCodeGen, target: LocalId) Error!ResolvedBase { const builder = self.builder orelse return error.CompilationFailed; const target_layout = self.localLayout(target); @@ -2719,6 +2844,7 @@ pub const MonoLlvmCodeGen = struct { .num_to_str => try self.emitNumToStr(target, GuardedList.at(arg_locals, 0)), .box_box => try self.emitBoxBox(target, GuardedList.at(arg_locals, 0)), .box_unbox => try self.emitBoxUnbox(target, GuardedList.at(arg_locals, 0)), + .box_prepare_update => try self.emitBoxPrepareUpdate(target, GuardedList.at(arg_locals, 0), unique_args), .erased_capture_load => try self.emitErasedCaptureLoad(target, GuardedList.at(arg_locals, 0)), .ptr_alloca => try self.emitPtrAlloca(target), .box_alloc_zeroed => try self.emitBoxAllocZeroed(target), @@ -4850,6 +4976,28 @@ pub const MonoLlvmCodeGen = struct { ); } + fn emitStaticDataLiteral(self: *MonoLlvmCodeGen, out: LocalSlot, id: lir.LIR.StaticDataId) Error!void { + if (out.size == 0) return; + try self.copyBytes(out.ptr, try self.staticDataGlobal(id, out.size), out.size, out.alignment); + } + + fn staticDataGlobal(self: *MonoLlvmCodeGen, id: lir.LIR.StaticDataId, size: u32) Error!LlvmBuilder.Value { + const raw_id: u32 = @intFromEnum(id); + if (self.static_data_globals.get(raw_id)) |value| return value; + + const builder = self.builder orelse return error.CompilationFailed; + const symbol_name = try lir.Program.staticDataSymbolName(self.allocator, id); + defer self.allocator.free(symbol_name); + + const arr_ty = builder.arrayType(@max(size, 1), .i8) catch return error.OutOfMemory; + const variable = builder.addVariable(builder.strtabString(symbol_name) catch return error.OutOfMemory, arr_ty, .default) catch return error.OutOfMemory; + variable.ptrConst(builder).global.setLinkage(.external, builder); + + const value = variable.toValue(builder); + try self.static_data_globals.put(raw_id, value); + return value; + } + fn emitBytesLiteral(self: *MonoLlvmCodeGen, out: LlvmBuilder.Value, literal: StrLiteral) Error!void { const builder = self.builder orelse return error.CompilationFailed; const wip = self.wip orelse return error.CompilationFailed; @@ -6458,6 +6606,48 @@ pub const MonoLlvmCodeGen = struct { if (self.slot(target).size > 0) try self.copyBytes(self.slot(target).ptr, ptr, self.slot(target).size, self.slot(target).alignment); } + fn emitBoxPrepareUpdate(self: *MonoLlvmCodeGen, target: LocalId, arg: LocalId, unique_args: u64) Error!void { + const builder = self.builder orelse return error.CompilationFailed; + const ptr_ty = try self.ptrType(); + const target_layout = self.localLayout(target); + switch (self.layoutValue(target_layout).tag) { + .box_of_zst => { + try self.storePointer(self.slot(target).ptr, builder.nullValue(ptr_ty) catch return error.OutOfMemory); + }, + .box => { + const abi = self.layouts().builtinBoxAbi(target_layout); + const enabled = abi.contains_refcounted and abi.elem_layout_idx != null; + const null_ptr = builder.nullValue(ptr_ty) catch return error.OutOfMemory; + const payload_incref = if (enabled) + (try self.declareRcHelper(.{ .op = .incref, .layout_idx = abi.elem_layout_idx.? }, .atomic)) + else + null; + const payload_decref = if (enabled) + (try self.declareRcHelper(.{ .op = .decref, .layout_idx = abi.elem_layout_idx.? }, .atomic)) + else + null; + const mode = if ((unique_args & 1) != 0) builtins.utils.UpdateMode.InPlace else builtins.utils.UpdateMode.Immutable; + const result = try self.callBuiltin( + "roc_builtins_box_prepare_update", + ptr_ty, + &.{ ptr_ty, self.ptrSizedIntType(), .i32, .i1, ptr_ty, ptr_ty, .i8, ptr_ty }, + &.{ + try self.loadPointer(self.slot(arg).ptr), + builder.intValue(self.ptrSizedIntType(), abi.elem_size) catch return error.OutOfMemory, + builder.intValue(.i32, abi.elem_alignment) catch return error.OutOfMemory, + builder.intValue(.i1, @intFromBool(enabled)) catch return error.OutOfMemory, + if (payload_incref) |func| func.toValue(builder) else null_ptr, + if (payload_decref) |func| func.toValue(builder) else null_ptr, + builder.intValue(.i8, @intFromEnum(mode)) catch return error.OutOfMemory, + self.rocOps(), + }, + ); + try self.storePointer(self.slot(target).ptr, result); + }, + else => return error.CompilationFailed, + } + } + fn emitErasedCaptureLoad(self: *MonoLlvmCodeGen, target: LocalId, arg: LocalId) Error!void { const capture_ptr = try self.loadPointer(self.slot(arg).ptr); if (self.slot(target).size > 0) try self.copyBytes(self.slot(target).ptr, capture_ptr, self.slot(target).size, self.slot(target).alignment); @@ -7957,6 +8147,40 @@ pub const MonoLlvmCodeGen = struct { }; } + /// The LLVM C-ABI carrier type for a register piece. LLVM IR has sub-i32 + /// integers, but WebAssembly's C ABI does not: narrow integer parameters + /// and returns travel as i32 values with explicit extension/truncation at + /// the ABI boundary. + fn cAbiPieceLlvmType(self: *MonoLlvmCodeGen, builder: *LlvmBuilder, piece: layout.abi.RegPiece) Error!LlvmBuilder.Type { + if ((self.abiTarget() == .wasm32 or self.abiTarget() == .wasm64) and + piece.class == .integer and piece.size < 4) + { + return .i32; + } + return pieceLlvmType(builder, piece); + } + + fn cAbiPieceIsSigned(self: *MonoLlvmCodeGen, layout_idx: layout.Idx) bool { + const direct_idx = switch (self.abiTarget()) { + .wasm32, .wasm64 => switch (layout.abi.wasm.classifyType(self.layouts(), layout_idx)) { + .direct => |idx| idx, + .indirect => layout_idx, + }, + else => layout_idx, + }; + + const lay = self.layoutValue(direct_idx); + return switch (lay.tag) { + .scalar => switch (lay.getScalar().tag) { + .int => direct_idx.isSigned(), + .frac => direct_idx == .dec, + .opaque_ptr, .str => false, + }, + .tag_union, .box, .box_of_zst, .ptr => false, + else => direct_idx.isSigned(), + }; + } + /// A type with the exact size and alignment of `layout_idx`, for a `byval`/`sret` /// pointer parameter (LLVM derives the memory convention and alignment from it). fn memoryLlvmTypeForLayout(self: *MonoLlvmCodeGen, builder: *LlvmBuilder, layout_idx: layout.Idx) Error!LlvmBuilder.Type { @@ -8107,14 +8331,6 @@ pub const MonoLlvmCodeGen = struct { } } - fn emitLinuxExitSyscall(self: *MonoLlvmCodeGen, code: LlvmBuilder.Value) Error!void { - switch (self.target.cpu.arch) { - .x86_64 => try self.emitX86_64LinuxExitSyscall(code), - .aarch64 => try self.emitAarch64LinuxExitSyscall(code), - else => return error.CompilationFailed, - } - } - fn emitX86_64LinuxWriteStdout(self: *MonoLlvmCodeGen, ptr: LlvmBuilder.Value, len: LlvmBuilder.Value) Error!void { const builder = self.builder orelse return error.CompilationFailed; const wip = self.wip orelse return error.CompilationFailed; @@ -8159,48 +8375,6 @@ pub const MonoLlvmCodeGen = struct { ) catch return error.OutOfMemory; } - fn emitX86_64LinuxExitSyscall(self: *MonoLlvmCodeGen, code: LlvmBuilder.Value) Error!void { - const builder = self.builder orelse return error.CompilationFailed; - const wip = self.wip orelse return error.CompilationFailed; - const usize_ty = self.ptrSizedIntType(); - const fn_ty = builder.fnType(.void, &.{ usize_ty, usize_ty }, .normal) catch return error.OutOfMemory; - - _ = wip.callAsm( - .none, - fn_ty, - .{ .sideeffect = true }, - builder.string("syscall") catch return error.OutOfMemory, - builder.string("{rax},{rdi},~{rcx},~{r11},~{memory}") catch return error.OutOfMemory, - &.{ - builder.intValue(usize_ty, 60) catch return error.OutOfMemory, - code, - }, - "", - ) catch return error.OutOfMemory; - _ = wip.@"unreachable"() catch return error.OutOfMemory; - } - - fn emitAarch64LinuxExitSyscall(self: *MonoLlvmCodeGen, code: LlvmBuilder.Value) Error!void { - const builder = self.builder orelse return error.CompilationFailed; - const wip = self.wip orelse return error.CompilationFailed; - const usize_ty = self.ptrSizedIntType(); - const fn_ty = builder.fnType(.void, &.{ usize_ty, usize_ty }, .normal) catch return error.OutOfMemory; - - _ = wip.callAsm( - .none, - fn_ty, - .{ .sideeffect = true }, - builder.string("svc #0") catch return error.OutOfMemory, - builder.string("{x8},{x0},~{memory}") catch return error.OutOfMemory, - &.{ - builder.intValue(usize_ty, 94) catch return error.OutOfMemory, - code, - }, - "", - ) catch return error.OutOfMemory; - _ = wip.@"unreachable"() catch return error.OutOfMemory; - } - /// Emit a hosted-function call using the platform C ABI: small arguments and the return /// travel in registers per `abi.lower`, with `*RocOps` threaded only when the signature /// touches Roc-managed memory. `arg_ptrs` point at each argument's value bytes; the result @@ -8252,10 +8426,10 @@ pub const MonoLlvmCodeGen = struct { .registers => |pieces| { ret_pieces = pieces; if (pieces.len == 1) { - ret_ty = try pieceLlvmType(builder, pieces[0]); + ret_ty = try self.cAbiPieceLlvmType(builder, pieces[0]); } else { const field_types = try arena.alloc(LlvmBuilder.Type, pieces.len); - for (pieces, field_types) |piece, *t| t.* = try pieceLlvmType(builder, piece); + for (pieces, field_types) |piece, *t| t.* = try self.cAbiPieceLlvmType(builder, piece); ret_ty = builder.structType(.normal, field_types) catch return error.OutOfMemory; } }, @@ -8286,8 +8460,9 @@ pub const MonoLlvmCodeGen = struct { const piece_ty = try pieceLlvmType(builder, piece); const src = try self.offsetPtr(arg_ptr, piece.offset); const val = wip.load(.normal, piece_ty, src, arg_align, "") catch return error.OutOfMemory; - try param_types.append(self.allocator, piece_ty); - try call_args.append(self.allocator, val); + const carrier_ty = try self.cAbiPieceLlvmType(builder, piece); + try param_types.append(self.allocator, carrier_ty); + try call_args.append(self.allocator, try self.coerceScalar(val, carrier_ty, self.cAbiPieceIsSigned(arg_layout))); } }, } @@ -8314,12 +8489,14 @@ pub const MonoLlvmCodeGen = struct { const ret_align = self.alignmentForLayout(ret_layout); if (ret_pieces.len == 1) { const dst = try self.offsetPtr(ret_ptr, ret_pieces[0].offset); - _ = wip.store(.normal, result, dst, ret_align) catch return error.OutOfMemory; + const store_val = try self.coerceScalar(result, try pieceLlvmType(builder, ret_pieces[0]), self.cAbiPieceIsSigned(ret_layout)); + _ = wip.store(.normal, store_val, dst, ret_align) catch return error.OutOfMemory; } else { for (ret_pieces, 0..) |piece, i| { const field = wip.extractValue(result, &.{@intCast(i)}, "") catch return error.OutOfMemory; const dst = try self.offsetPtr(ret_ptr, piece.offset); - _ = wip.store(.normal, field, dst, ret_align) catch return error.OutOfMemory; + const store_val = try self.coerceScalar(field, try pieceLlvmType(builder, piece), self.cAbiPieceIsSigned(ret_layout)); + _ = wip.store(.normal, store_val, dst, ret_align) catch return error.OutOfMemory; } } } diff --git a/src/backend/wasm/WasmCodeGen.zig b/src/backend/wasm/WasmCodeGen.zig index 25aa1e6bbf9..2122a3b24f9 100644 --- a/src/backend/wasm/WasmCodeGen.zig +++ b/src/backend/wasm/WasmCodeGen.zig @@ -260,6 +260,8 @@ rc_helper_table_indices: std.AutoHashMap(u64, u32), proc_table_indices: std.AutoHashMap(u32, u32), /// Map from LIR string backing id → wasm data offset for the backing bytes. static_str_offsets: std.AutoHashMap(u32, DataAddress), +/// Map from LIR static-data id -> undefined/defined wasm data symbol. +static_data_symbols: std.AutoHashMap(u32, SymbolIndex), /// Owned names used by generated data segments and local data symbols. static_data_names: std.ArrayList([]u8), static_data_name_counter: u32 = 0, @@ -490,6 +492,7 @@ pub fn init(allocator: Allocator, store: *const LirStore, layout_store: *const L .rc_helper_table_indices = std.AutoHashMap(u64, u32).init(allocator), .proc_table_indices = std.AutoHashMap(u32, u32).init(allocator), .static_str_offsets = std.AutoHashMap(u32, DataAddress).init(allocator), + .static_data_symbols = std.AutoHashMap(u32, SymbolIndex).init(allocator), .static_data_names = .empty, .func_type_cache = std.StringHashMap(u32).init(allocator), .func_type_key_scratch = .empty, @@ -558,6 +561,7 @@ pub fn deinit(self: *Self) void { self.rc_helper_table_indices.deinit(); self.proc_table_indices.deinit(); self.static_str_offsets.deinit(); + self.static_data_symbols.deinit(); for (self.static_data_names.items) |name| { self.allocator.free(name); } @@ -1168,6 +1172,36 @@ fn emitDataAddressConst(self: *Self, address: DataAddress, addend: i32) Allocato } } +fn staticDataSymbol(self: *Self, id: LIR.StaticDataId) Allocator.Error!SymbolIndex { + const raw_id: u32 = @intFromEnum(id); + if (self.static_data_symbols.get(raw_id)) |symbol| return symbol; + + const symbol_name = try lir.Program.staticDataSymbolName(self.allocator, id); + errdefer self.allocator.free(symbol_name); + + const symbol = if (self.module.findSymbolByNameAndKind(symbol_name, .data)) |existing| + SymbolIndex.fromRaw(existing) + else + try self.module.addUndefinedDataSymbol(symbol_name); + + try self.static_data_names.append(self.allocator, symbol_name); + try self.static_data_symbols.put(raw_id, symbol); + return symbol; +} + +fn emitStaticDataAddressConst(self: *Self, id: LIR.StaticDataId, addend: i32) Allocator.Error!void { + self.currentCode().append(self.allocator, Op.i32_const) catch return error.OutOfMemory; + const code_pos: u32 = @intCast(self.currentCode().items.len); + try self.currentBody().addOffsetRelocation( + self.allocator, + .memory_addr_sleb, + code_pos, + try self.staticDataSymbol(id), + addend, + ); + WasmModule.appendPaddedI32(self.allocator, self.currentCode(), 0) catch return error.OutOfMemory; +} + fn emitCallIndirect(self: *Self, type_idx: u32) Allocator.Error!void { self.currentCode().append(self.allocator, Op.call_indirect) catch return error.OutOfMemory; if (self.relocatable_object) { @@ -3391,6 +3425,7 @@ fn collectProcLocals( .assign_packed_erased_fn => |assign| { try recordProcLocal(locals, assign.target); if (assign.capture) |capture| try recordProcLocal(locals, capture); + if (assign.reuse) |reuse| try recordProcLocal(locals, reuse); try work.append(wa, assign.next); }, .assign_low_level => |assign| { @@ -3418,6 +3453,17 @@ fn collectProcLocals( } try work.append(wa, assign.next); }, + .store_struct => |assign| { + try recordProcLocal(locals, assign.dest); + const fields = self.store.getLocalSpan(assign.fields); + for (0..fields.len) |index| try recordProcLocal(locals, GuardedList.at(fields, index)); + try work.append(wa, assign.next); + }, + .store_tag => |assign| { + try recordProcLocal(locals, assign.dest); + if (assign.payload) |payload| try recordProcLocal(locals, payload); + try work.append(wa, assign.next); + }, .set_local => |assign| { try recordProcLocal(locals, assign.target); try recordProcLocal(locals, assign.value); @@ -8181,7 +8227,7 @@ fn generateCFStmtNode(self: *Self, work: *std.ArrayList(StmtWork), wa: Allocator try work.append(wa, .{ .node = .{ .stmt_id = assign.next, .stop = stop } }); }, .assign_literal => |assign| { - try self.generateLiteral(assign.value); + try self.generateLiteral(assign.value, self.procLocalLayoutIdx(assign.target)); try self.bindAssignedLocal(assign.target); try work.append(wa, .{ .node = .{ .stmt_id = assign.next, .stop = stop } }); }, @@ -8213,6 +8259,8 @@ fn generateCFStmtNode(self: *Self, work: *std.ArrayList(StmtWork), wa: Allocator .target_layout = self.procLocalLayoutIdx(assign.target), .capture_layout = assign.capture_layout, .on_drop = assign.on_drop, + .reuse = assign.reuse, + .reuse_unique = assign.reuse_unique, }); try self.bindAssignedLocal(assign.target); try work.append(wa, .{ .node = .{ .stmt_id = assign.next, .stop = stop } }); @@ -8254,9 +8302,27 @@ fn generateCFStmtNode(self: *Self, work: *std.ArrayList(StmtWork), wa: Allocator try self.bindAssignedLocal(assign.target); try work.append(wa, .{ .node = .{ .stmt_id = assign.next, .stop = stop } }); }, + .store_struct => |assign| { + try self.generateStoreStruct(.{ + .dest = assign.dest, + .fields = assign.fields, + .struct_layout = assign.struct_layout, + }); + try work.append(wa, .{ .node = .{ .stmt_id = assign.next, .stop = stop } }); + }, + .store_tag => |assign| { + try self.generateStoreTag(.{ + .dest = assign.dest, + .union_layout = assign.tag_layout, + .variant_index = assign.variant_index, + .discriminant = assign.discriminant, + .payload = assign.payload, + }); + try work.append(wa, .{ .node = .{ .stmt_id = assign.next, .stop = stop } }); + }, .set_local => |assign| { try self.emitProcLocal(assign.value); - try self.emitLocalSet(try self.getOrAllocTypedLocal(assign.target, try self.procLocalValType(assign.target))); + try self.bindLocalFromWasmStack(assign.target); try work.append(wa, .{ .node = .{ .stmt_id = assign.next, .stop = stop } }); }, .debug => |debug_stmt| { @@ -8566,7 +8632,7 @@ fn generateCFStmtNode(self: *Self, work: *std.ArrayList(StmtWork), wa: Allocator } } -fn generateLiteral(self: *Self, value: LIR.LiteralValue) Allocator.Error!void { +fn generateLiteral(self: *Self, value: LIR.LiteralValue, target_layout: layout.Idx) Allocator.Error!void { switch (value) { .i64_literal => |lit| { switch (try self.resolveValType(lit.layout_idx)) { @@ -8596,6 +8662,7 @@ fn generateLiteral(self: *Self, value: LIR.LiteralValue) Allocator.Error!void { }, .dec_literal => |lit| try self.generateI128Literal(lit), .str_literal => |str_idx| try self.generateStrLiteral(str_idx), + .static_data => |id| try self.generateStaticDataLiteral(id, target_layout), .bytes_literal => |bytes_idx| try self.generateBytesLiteral(bytes_idx), .null_ptr => { self.currentCode().append(self.allocator, Op.i32_const) catch return error.OutOfMemory; @@ -8633,6 +8700,37 @@ fn generateIntLiteralForLayout(self: *Self, value: i128, layout_idx: layout.Idx) } } +fn generateStaticDataLiteral(self: *Self, id: LIR.StaticDataId, target_layout: layout.Idx) Allocator.Error!void { + const runtime_layout = self.runtimeRepresentationLayoutIdx(target_layout); + const size = try self.layoutStorageByteSize(runtime_layout); + if (size == 0) { + self.currentCode().append(self.allocator, Op.i32_const) catch return error.OutOfMemory; + WasmModule.leb128WriteI32(self.allocator, self.currentCode(), 0) catch return error.OutOfMemory; + return; + } + + const repr = try WasmLayout.wasmReprWithStore(runtime_layout, self.getLayoutStore()); + switch (repr) { + .primitive => |vt| { + try self.emitStaticDataAddressConst(id, 0); + try self.emitLoadOpSized(vt, size, 0); + }, + .stack_memory => { + const slot = try self.allocStackMemory(size, try self.layoutStorageByteAlign(runtime_layout)); + const dst = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitFpOffset(slot); + try self.emitLocalSet(dst); + + const src = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitStaticDataAddressConst(id, 0); + try self.emitLocalSet(src); + + try self.emitMemCopy(dst, 0, src, size); + try self.emitLocalGet(dst); + }, + } +} + /// Emit a diagnostic RocOps callback (dbg/expect_failed/crashed) following the C ABI: /// (*RocOps, bytes_ptr, len) -> (). The bytes pointer and length are sourced from the /// two i32 fields packed at `args_slot` (field 0 = bytes_ptr, field 4 = len). @@ -8757,6 +8855,10 @@ fn generateI128Literal(self: *Self, value: i128) Allocator.Error!void { } fn bindAssignedLocal(self: *Self, target: ProcLocalId) Allocator.Error!void { + try self.bindLocalFromWasmStack(target); +} + +fn bindLocalFromWasmStack(self: *Self, target: ProcLocalId) Allocator.Error!void { const ls = self.getLayoutStore(); const runtime_layout = self.runtimeRepresentationLayoutIdx(self.procLocalLayoutIdx(target)); const repr = try WasmLayout.wasmReprWithStore(runtime_layout, ls); @@ -9143,14 +9245,56 @@ fn generatePackedErasedFn(self: *Self, c: anytype) Allocator.Error!void { } } } - const payload_size = builtins.erased_callable.payloadSize(capture_size); - try self.emitHeapAllocWithRefcountConst( - @intCast(payload_size), - builtins.erased_callable.payload_alignment, - builtins.erased_callable.allocation_has_refcounted_children, - ); const payload_ptr = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; - try self.emitLocalSet(payload_ptr); + if (c.reuse) |reuse| { + try self.emitProcLocal(reuse); + const reuse_ptr = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitLocalSet(reuse_ptr); + + if (c.reuse_unique) { + try self.emitLocalGet(reuse_ptr); + try self.emitLocalSet(payload_ptr); + try self.emitErasedCallableOnDrop(payload_ptr); + } else { + const rc_val = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitLoadI32AtPtrOffset(reuse_ptr, -4, rc_val); + + try self.emitLocalGet(rc_val); + try self.emitI32Const(1); + self.currentCode().append(self.allocator, Op.i32_eq) catch return error.OutOfMemory; + self.currentCode().append(self.allocator, Op.@"if") catch return error.OutOfMemory; + self.currentCode().append(self.allocator, @intFromEnum(BlockType.void)) catch return error.OutOfMemory; + + try self.emitLocalGet(reuse_ptr); + try self.emitLocalSet(payload_ptr); + try self.emitErasedCallableOnDrop(payload_ptr); + + self.currentCode().append(self.allocator, Op.@"else") catch return error.OutOfMemory; + + const payload_size = builtins.erased_callable.payloadSize(capture_size); + try self.emitHeapAllocWithRefcountConst( + @intCast(payload_size), + builtins.erased_callable.payload_alignment, + builtins.erased_callable.allocation_has_refcounted_children, + ); + try self.emitLocalSet(payload_ptr); + try self.emitDataPtrDecref( + reuse_ptr, + builtins.erased_callable.payload_alignment, + builtins.erased_callable.allocation_has_refcounted_children, + ); + + self.currentCode().append(self.allocator, Op.end) catch return error.OutOfMemory; + } + } else { + const payload_size = builtins.erased_callable.payloadSize(capture_size); + try self.emitHeapAllocWithRefcountConst( + @intCast(payload_size), + builtins.erased_callable.payload_alignment, + builtins.erased_callable.allocation_has_refcounted_children, + ); + try self.emitLocalSet(payload_ptr); + } const proc_key: u32 = @intFromEnum(c.proc); const table_idx = self.proc_table_indices.get(proc_key) orelse { @@ -9895,6 +10039,57 @@ fn generateTag(self: *Self, t: anytype) Allocator.Error!void { WasmModule.leb128WriteU32(self.allocator, self.currentCode(), base_local) catch return error.OutOfMemory; } +fn generateStoreStruct(self: *Self, s: anytype) Allocator.Error!void { + try self.generateStoreAggregateValue(s.dest, s.struct_layout, .{ .struct_ = .{ + .fields = s.fields, + .struct_layout = s.struct_layout, + } }); +} + +fn generateStoreTag(self: *Self, t: anytype) Allocator.Error!void { + try self.generateStoreAggregateValue(t.dest, t.union_layout, .{ .tag = .{ + .union_layout = t.union_layout, + .variant_index = t.variant_index, + .discriminant = t.discriminant, + .payload = t.payload, + } }); +} + +fn generateStoreAggregateValue(self: *Self, dest: ProcLocalId, value_layout: layout.Idx, value: union(enum) { + struct_: struct { + fields: ProcLocalSpan, + struct_layout: layout.Idx, + }, + tag: struct { + union_layout: layout.Idx, + variant_index: u16, + discriminant: u16, + payload: ?ProcLocalId, + }, +}) Allocator.Error!void { + const value_size = try self.layoutByteSize(value_layout); + + try self.emitProcLocal(dest); + const ptr_local = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitLocalSet(ptr_local); + + if (value_size == 0) return; + + switch (value) { + .struct_ => |s| try self.generateStruct(s), + .tag => |t| try self.generateTag(t), + } + + if (try self.isCompositeLayout(value_layout)) { + const src_local = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitLocalSet(src_local); + try self.emitMemCopy(ptr_local, 0, src_local, value_size); + } else { + const value_vt = try self.resolveValType(value_layout); + try self.emitStoreToMemSized(ptr_local, 0, value_vt, value_size); + } +} + /// Generate a discriminant switch expression. /// Evaluates the tag union value, loads its discriminant, and generates /// cascading if/else branches indexed by discriminant value. @@ -12054,6 +12249,90 @@ fn generateLowLevel(self: *Self, ll: anytype) Allocator.Error!void { } } }, + .box_prepare_update => { + // box_prepare_update(box_ptr) -> box_ptr + // Return a unique payload allocation, copying and retaining nested + // payload children when the consumed box was shared or static. + const box_expr = GuardedList.at(args, 0); + const ls = self.getLayoutStore(); + const ret_layout = ls.getLayout(ll.ret_layout); + + if (ret_layout.tag == .box_of_zst) { + _ = try self.emitProcLocal(box_expr); + self.currentCode().append(self.allocator, Op.drop) catch return error.OutOfMemory; + try self.emitI32Const(0); + } else { + const box_abi = ls.builtinBoxAbi(ll.ret_layout); + const elem_size = box_abi.elem_size; + if (elem_size == 0) { + _ = try self.emitProcLocal(box_expr); + self.currentCode().append(self.allocator, Op.drop) catch return error.OutOfMemory; + try self.emitI32Const(0); + } else { + try self.emitProcLocal(box_expr); + const box_ptr = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitLocalSet(box_ptr); + + if ((ll.unique_args & 1) != 0) { + try self.emitLocalGet(box_ptr); + } else { + const result_ptr = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + + try self.emitLocalGet(box_ptr); + self.currentCode().append(self.allocator, Op.i32_eqz) catch return error.OutOfMemory; + self.currentCode().append(self.allocator, Op.@"if") catch return error.OutOfMemory; + self.currentCode().append(self.allocator, @intFromEnum(BlockType.void)) catch return error.OutOfMemory; + try self.emitI32Const(0); + try self.emitLocalSet(result_ptr); + self.currentCode().append(self.allocator, Op.@"else") catch return error.OutOfMemory; + + const masked_ptr = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + const rc_ptr = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + const rc_val = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitLocalGet(box_ptr); + try self.emitI32Const(-4); + self.currentCode().append(self.allocator, Op.i32_and) catch return error.OutOfMemory; + try self.emitLocalSet(masked_ptr); + try self.emitLocalGet(masked_ptr); + try self.emitI32Const(-4); + self.currentCode().append(self.allocator, Op.i32_add) catch return error.OutOfMemory; + try self.emitLocalSet(rc_ptr); + try self.emitLoadI32AtPtrOffset(rc_ptr, 0, rc_val); + + try self.emitLocalGet(rc_val); + try self.emitI32Const(1); + self.currentCode().append(self.allocator, Op.i32_eq) catch return error.OutOfMemory; + self.currentCode().append(self.allocator, Op.@"if") catch return error.OutOfMemory; + self.currentCode().append(self.allocator, @intFromEnum(BlockType.void)) catch return error.OutOfMemory; + try self.emitLocalGet(box_ptr); + try self.emitLocalSet(result_ptr); + self.currentCode().append(self.allocator, Op.@"else") catch return error.OutOfMemory; + + try self.emitHeapAllocWithRefcountConst(elem_size, box_abi.elem_alignment, box_abi.contains_refcounted); + const fresh_ptr = self.storage.allocAnonymousLocal(.i32) catch return error.OutOfMemory; + try self.emitLocalSet(fresh_ptr); + try self.emitMemCopy(fresh_ptr, 0, box_ptr, elem_size); + + if (box_abi.contains_refcounted) { + if (box_abi.elem_layout_idx) |elem_layout_idx| { + const helper_key = RcHelperKey{ .op = .incref, .layout_idx = elem_layout_idx }; + if (ls.rcHelperPlan(helper_key) != .noop) { + try self.emitExplicitRcHelperCallForValuePtr(helper_key, .atomic, fresh_ptr, 1); + } + } + } + + try self.emitDataPtrDecref(box_ptr, box_abi.elem_alignment, box_abi.contains_refcounted); + try self.emitLocalGet(fresh_ptr); + try self.emitLocalSet(result_ptr); + + self.currentCode().append(self.allocator, Op.end) catch return error.OutOfMemory; + self.currentCode().append(self.allocator, Op.end) catch return error.OutOfMemory; + try self.emitLocalGet(result_ptr); + } + } + } + }, .erased_capture_load => { const capture_ptr_expr = GuardedList.at(args, 0); const result_size = try self.layoutByteSize(ll.ret_layout); diff --git a/src/backend/wasm/WasmModule.zig b/src/backend/wasm/WasmModule.zig index 05465c1ca4b..89a6381ff7b 100644 --- a/src/backend/wasm/WasmModule.zig +++ b/src/backend/wasm/WasmModule.zig @@ -317,6 +317,9 @@ pub const DefinedGlobal = struct { const DataSegment = struct { offset: u32, // offset in linear memory data: []u8, // bytes to place + /// Segment reserves zero-filled memory. This is object segment metadata, not + /// a byte-pattern optimization. + zero_fill: bool = false, /// Byte offset of this segment's payload within the original data section body. /// Used to normalize reloc.DATA entries during preload. section_offset: u32 = 0, @@ -328,6 +331,11 @@ const DataSegment = struct { flags: u32 = 0, }; +fn isZeroFillSegmentName(name: ?[]const u8) bool { + const text = name orelse return false; + return std.mem.eql(u8, text, ".bss") or std.mem.startsWith(u8, text, ".bss."); +} + /// An imported function pub const Import = struct { module_name: []const u8, @@ -418,6 +426,7 @@ global_imports: std.ArrayList(GlobalImport), /// Table imports (e.g. __indirect_function_table for PIC modules). table_imports: std.ArrayList(TableImport), data_segments: std.ArrayList(DataSegment), +omit_zero_fill_data_segments: bool, /// Next available offset for data placement in linear memory (grows up from 0). data_offset: u32, has_memory: bool, @@ -474,6 +483,7 @@ pub fn init(allocator: Allocator) Self { .global_imports = .empty, .table_imports = .empty, .data_segments = .empty, + .omit_zero_fill_data_segments = false, .data_offset = 1024, // reserve first 1KB for future use .has_memory = false, .memory_import = false, @@ -729,7 +739,8 @@ pub fn findDefinedFunctionIndexExact(self: *const Self, name: []const u8) Symbol return self.linking.symbol_table.items[symbol.raw()].index; } -fn findSymbolByNameAndKind(self: *const Self, name: []const u8, kind: WasmLinking.SymKind) ?u32 { +/// Find a symbol table index by exact symbol name and linking symbol kind. +pub fn findSymbolByNameAndKind(self: *const Self, name: []const u8, kind: WasmLinking.SymKind) ?u32 { for (self.linking.symbol_table.items, 0..) |sym, i| { if (sym.kind != kind) continue; const sym_name = sym.resolveName(self.imports.items, self.global_imports.items, self.table_imports.items) orelse continue; @@ -789,6 +800,44 @@ fn resolveUndefinedFunctionSymbols( return first_match; } +fn isUndefinedDataNamed(self: *const Self, sym: WasmLinking.SymInfo, name: []const u8) bool { + if (sym.kind != .data or !sym.isUndefined()) return false; + const sym_name = sym.resolveName(self.imports.items, self.global_imports.items, self.table_imports.items) orelse return false; + return std.mem.eql(u8, sym_name, name); +} + +fn resolveUndefinedDataSymbols( + self: *Self, + name: []const u8, + segment_index: u32, + data_offset: u32, + data_size: u32, + defined_flags: u32, + defined_name: ?[]const u8, +) ?u32 { + var first_match: ?u32 = null; + for (self.linking.symbol_table.items, 0..) |sym, i| { + if (!self.isUndefinedDataNamed(sym, name)) continue; + if (first_match == null) first_match = @intCast(i); + } + + if (first_match == null) return null; + + for (self.linking.symbol_table.items) |*sym| { + if (!self.isUndefinedDataNamed(sym.*, name)) continue; + sym.* = .{ + .kind = .data, + .flags = defined_flags & ~WasmLinking.SymFlag.UNDEFINED, + .name = defined_name orelse name, + .index = segment_index, + .data_offset = data_offset, + .data_size = data_size, + }; + } + + return first_match; +} + /// Return the wasm type index for an imported or defined function. pub fn functionType(self: *const Self, function: FunctionIndex) u32 { const raw = function.raw(); @@ -836,6 +885,27 @@ pub fn addExport(self: *Self, name: []const u8, kind: ExportKind, idx: u32) Allo }); } +/// Remove function exports whose names are link-time plumbing rather than +/// part of the final wasm module's host-visible ABI. +pub fn removeFunctionExports(self: *Self, names: []const []const u8) void { + var write_idx: usize = 0; + for (self.exports.items) |exp| { + if (exp.kind == .func and stringInSlice(exp.name, names)) { + continue; + } + self.exports.items[write_idx] = exp; + write_idx += 1; + } + self.exports.items.len = write_idx; +} + +fn stringInSlice(needle: []const u8, haystack: []const []const u8) bool { + for (haystack) |candidate| { + if (std.mem.eql(u8, needle, candidate)) return true; + } + return false; +} + /// Enable memory section with the given minimum page count. pub fn enableMemory(self: *Self, min_pages: u32) void { self.has_memory = true; @@ -878,6 +948,7 @@ pub fn addDataSegmentWithInfo( try self.data_segments.append(self.allocator, .{ .offset = offset, .data = data_copy, + .zero_fill = isZeroFillSegmentName(name), .section_offset = 0, .name = name, .alignment = alignmentLog2(alignment), @@ -909,6 +980,50 @@ pub fn addDataSymbol( return SymbolIndex.fromRaw(raw_symbol); } +/// Add an undefined data symbol that relocations can target before the defining +/// data-only static module has been merged. +pub fn addUndefinedDataSymbol(self: *Self, name: []const u8) Allocator.Error!SymbolIndex { + if (self.findSymbolByNameAndKind(name, .data)) |existing| { + return SymbolIndex.fromRaw(existing); + } + + const raw_symbol: u32 = @intCast(self.linking.symbol_table.items.len); + try self.linking.symbol_table.append(self.allocator, .{ + .kind = .data, + .flags = WasmLinking.SymFlag.UNDEFINED | WasmLinking.SymFlag.EXPLICIT_NAME, + .name = name, + .index = 0, + }); + return SymbolIndex.fromRaw(raw_symbol); +} + +fn addOrDefineDataSymbol( + self: *Self, + segment_index: u32, + name: []const u8, + data_offset: u32, + size: u32, + flags: u32, +) Allocator.Error!SymbolIndex { + std.debug.assert(segment_index < self.data_segments.items.len); + if (self.findSymbolByNameAndKind(name, .data)) |existing| { + const sym = &self.linking.symbol_table.items[existing]; + if (sym.isUndefined()) { + sym.* = .{ + .kind = .data, + .flags = flags, + .name = name, + .index = segment_index, + .data_offset = data_offset, + .data_size = size, + }; + } + return SymbolIndex.fromRaw(existing); + } + + return try self.addDataSymbol(segment_index, name, data_offset, size, flags); +} + /// Build a relocatable data-only module from materialized static data exports. pub fn staticDataModule(allocator: Allocator, exports: []const StaticDataExport) StaticDataError!Self { var module = Self.init(allocator); @@ -933,12 +1048,14 @@ pub fn addStaticDataExports(self: *Self, exports: []const StaticDataExport) Stat 0, ); - const symbol_flags: u32 = if (data_export.is_global) + const symbol_flags: u32 = if (data_export.is_exported) 0 + else if (data_export.is_global) + WasmLinking.SymFlag.VISIBILITY_HIDDEN else WasmLinking.SymFlag.BINDING_LOCAL | WasmLinking.SymFlag.VISIBILITY_HIDDEN; const symbol_offset: usize = @intCast(data_export.symbol_offset); - _ = try self.addDataSymbol( + _ = try self.addOrDefineDataSymbol( segment_indices[i], data_export.symbol_name, data_export.symbol_offset, @@ -1636,11 +1753,24 @@ pub fn mergeModuleMode(self: *Self, source: *const Self, mode: MergeMode) MergeE // Defined data — keep the symbol's segment-relative offset. // The segment itself was remapped above; final relocation // resolution computes the absolute address from the segment. - const new_sym_idx: u32 = @intCast(self.linking.symbol_table.items.len); const new_segment_idx = if (src_sym.index < data_segment_remap.len) data_segment_remap[src_sym.index] else src_sym.index; + if (src_name) |name| { + if (self.resolveUndefinedDataSymbols( + name, + new_segment_idx, + src_sym.data_offset, + src_sym.data_size, + src_sym.flags, + src_sym.name, + )) |existing| { + symbol_remap[src_sym_idx] = existing; + continue; + } + } + const new_sym_idx: u32 = @intCast(self.linking.symbol_table.items.len); try self.linking.symbol_table.append(gpa, .{ .kind = .data, .flags = src_sym.flags, @@ -1928,6 +2058,18 @@ fn patchGlobalIndexCodeRelocation( const table_idx = try self.ensureTableElement(sym.index); overwritePaddedU32(target_bytes, patch_offset, table_idx); }, + .data => { + const o: usize = @intCast(patch_offset); + if (o == 0) unreachable; + if (target_bytes[o - 1] != Op.global_get) unreachable; + if (sym.isUndefined()) unreachable; + std.debug.assert(sym.index < self.data_segments.items.len); + + target_bytes[o - 1] = Op.i32_const; + const segment = self.data_segments.items[sym.index]; + const address = segment.offset + sym.data_offset; + overwritePaddedU32(target_bytes, patch_offset, address); + }, else => unreachable, } } @@ -2595,6 +2737,7 @@ pub fn removeMemoryAndTableImports(self: *Self) void { pub const FinalMemoryConfig = struct { stack_bytes: u32, import_memory: bool = false, + imported_memory_zeroed: bool = false, minimum_memory: ?usize = null, maximum_memory: ?usize = null, export_memory: bool = true, @@ -2646,6 +2789,7 @@ pub fn finalizeMemoryAndTableWithConfig(self: *Self, config: FinalMemoryConfig) // Ensure memory is present in the final module. self.has_memory = true; self.memory_import = config.import_memory; + self.omit_zero_fill_data_segments = !config.import_memory or config.imported_memory_zeroed; // Configure table if we have any function indices to place in it. if (self.table_func_indices.items.len > 0) { @@ -2706,6 +2850,7 @@ pub fn preload(allocator: Allocator, bytes: []const u8, require_relocatable: boo segment.name = info.name; segment.alignment = info.alignment; segment.flags = info.flags; + segment.zero_fill = isZeroFillSegmentName(info.name); } // Adjust reloc.CODE offsets: they are relative to the code section body @@ -3043,6 +3188,7 @@ fn parseDataSection_(self: *Self, bytes: []const u8, cursor: *usize) ParseError! try self.data_segments.append(self.allocator, .{ .offset = 0, .data = data_copy, + .zero_fill = false, .section_offset = @intCast(data_start - section_body_start), .name = null, .alignment = 0, @@ -3062,6 +3208,7 @@ fn parseDataSection_(self: *Self, bytes: []const u8, cursor: *usize) ParseError! try self.data_segments.append(self.allocator, .{ .offset = offset, .data = data_copy, + .zero_fill = false, .section_offset = @intCast(data_start - section_body_start), .name = null, .alignment = 0, @@ -3167,8 +3314,8 @@ pub fn encode(self: *Self, allocator: Allocator) Allocator.Error![]u8 { } // Data section - if (self.data_segments.items.len > 0) { - try self.encodeDataSection(allocator, &output); + if (self.encodedDataSegmentCount(self.omit_zero_fill_data_segments) > 0) { + try self.encodeDataSection(allocator, &output, self.omit_zero_fill_data_segments); } return output.toOwnedSlice(allocator); @@ -3220,9 +3367,9 @@ pub fn encodeRelocatable(self: *Self, allocator: Allocator) RelocatableEncodeErr break :blk count_leb_size; } else 0; - if (self.data_segments.items.len > 0) { + if (self.encodedDataSegmentCount(false) > 0) { data_section_index = section_index; - try self.encodeDataSection(allocator, &output); + try self.encodeDataSection(allocator, &output, false); section_index += 1; } @@ -3672,12 +3819,26 @@ fn encodeCodeSection(self: *Self, gpa: Allocator, output: *std.ArrayList(u8)) Al try output.appendSlice(gpa, section_data.items); } -fn encodeDataSection(self: *Self, gpa: Allocator, output: *std.ArrayList(u8)) Allocator.Error!void { +fn shouldEncodeDataSegment(segment: DataSegment, omit_zero_fill: bool) bool { + return !(omit_zero_fill and segment.zero_fill); +} + +fn encodedDataSegmentCount(self: *const Self, omit_zero_fill: bool) u32 { + var count: u32 = 0; + for (self.data_segments.items) |segment| { + if (shouldEncodeDataSegment(segment, omit_zero_fill)) count += 1; + } + return count; +} + +fn encodeDataSection(self: *Self, gpa: Allocator, output: *std.ArrayList(u8), omit_zero_fill: bool) Allocator.Error!void { var section_data: std.ArrayList(u8) = .empty; defer section_data.deinit(gpa); - try leb128WriteU32(gpa, §ion_data, @intCast(self.data_segments.items.len)); + try leb128WriteU32(gpa, §ion_data, self.encodedDataSegmentCount(omit_zero_fill)); for (self.data_segments.items) |*ds| { + if (!shouldEncodeDataSegment(ds.*, omit_zero_fill)) continue; + // Active segment for memory 0 try leb128WriteU32(gpa, §ion_data, 0); // flags: active, memory 0 // Offset expression: i32.const ; end @@ -4384,6 +4545,24 @@ test "preload — parses export section" { try std.testing.expectEqual(@as(u32, 1), module.exports.items[0].idx); } +test "removeFunctionExports — removes only named function exports" { + const allocator = std.testing.allocator; + var module = init(allocator); + defer module.deinit(); + + try module.addExport("start", .func, 0); + try module.addExport("host_unused", .func, 1); + try module.addExport("memory", .memory, 0); + + module.removeFunctionExports(&.{"host_unused"}); + + try std.testing.expectEqual(@as(usize, 2), module.exports.items.len); + try std.testing.expectEqualStrings("start", module.exports.items[0].name); + try std.testing.expectEqual(ExportKind.func, module.exports.items[0].kind); + try std.testing.expectEqualStrings("memory", module.exports.items[1].name); + try std.testing.expectEqual(ExportKind.memory, module.exports.items[1].kind); +} + test "preload — parses memory section" { const allocator = std.testing.allocator; const wasm_bytes = try buildTestRelocatableModule(allocator); @@ -5628,6 +5807,113 @@ test "mergeModule + resolveDataRelocations — patches merged data segment bytes try std.testing.expectEqual(target_segment.offset, patched); } +const EncodedDataSummary = struct { + count: u32, + payload_len: u32, +}; + +fn encodedDataSummary(bytes: []const u8) ParseError!EncodedDataSummary { + if (bytes.len < 8) return error.UnexpectedEnd; + var cursor: usize = 8; + while (cursor < bytes.len) { + const section_id = bytes[cursor]; + cursor += 1; + const section_size = try readU32(bytes, &cursor); + const section_end = cursor + section_size; + if (section_end > bytes.len) return error.UnexpectedEnd; + + if (section_id == @intFromEnum(SectionId.data_section)) { + const count = try readU32(bytes, &cursor); + var payload_len: u32 = 0; + for (0..count) |_| { + const flags = try readU32(bytes, &cursor); + switch (flags) { + 0 => { + if (cursor >= bytes.len) return error.UnexpectedEnd; + if (bytes[cursor] != Op.i32_const) return error.InvalidSection; + cursor += 1; + _ = try readI32(bytes, &cursor); + if (cursor >= bytes.len) return error.UnexpectedEnd; + if (bytes[cursor] != Op.end) return error.InvalidSection; + cursor += 1; + }, + 1 => {}, + 2 => { + _ = try readU32(bytes, &cursor); + if (cursor >= bytes.len) return error.UnexpectedEnd; + if (bytes[cursor] != Op.i32_const) return error.InvalidSection; + cursor += 1; + _ = try readI32(bytes, &cursor); + if (cursor >= bytes.len) return error.UnexpectedEnd; + if (bytes[cursor] != Op.end) return error.InvalidSection; + cursor += 1; + }, + else => return error.InvalidSection, + } + const len = try readU32(bytes, &cursor); + if (cursor + len > section_end) return error.UnexpectedEnd; + cursor += len; + payload_len += len; + } + return .{ .count = count, .payload_len = payload_len }; + } + + cursor = section_end; + } + return .{ .count = 0, .payload_len = 0 }; +} + +test "encode — omits bss payload when final memory starts zero-filled" { + const allocator = std.testing.allocator; + var module = Self.init(allocator); + defer module.deinit(); + + _ = try module.addDataSegmentWithInfo("DATA", 4, ".data.test", 0); + _ = try module.addDataSegmentWithInfo(&([_]u8{0} ** 64), 16, ".bss.heap", 0); + try std.testing.expect(module.data_segments.items[1].zero_fill); + + try module.finalizeMemoryAndTableWithConfig(.{ + .stack_bytes = 16, + .import_memory = true, + .imported_memory_zeroed = true, + .minimum_memory = 65536, + .maximum_memory = 65536, + .export_memory = false, + }); + + const encoded = try module.encode(allocator); + defer allocator.free(encoded); + + const summary = try encodedDataSummary(encoded); + try std.testing.expectEqual(@as(u32, 1), summary.count); + try std.testing.expectEqual(@as(u32, 4), summary.payload_len); +} + +test "encode — keeps bss payload when imported memory may be uninitialized" { + const allocator = std.testing.allocator; + var module = Self.init(allocator); + defer module.deinit(); + + _ = try module.addDataSegmentWithInfo("DATA", 4, ".data.test", 0); + _ = try module.addDataSegmentWithInfo(&([_]u8{0} ** 64), 16, ".bss.heap", 0); + + try module.finalizeMemoryAndTableWithConfig(.{ + .stack_bytes = 16, + .import_memory = true, + .imported_memory_zeroed = false, + .minimum_memory = 65536, + .maximum_memory = 65536, + .export_memory = false, + }); + + const encoded = try module.encode(allocator); + defer allocator.free(encoded); + + const summary = try encodedDataSummary(encoded); + try std.testing.expectEqual(@as(u32, 2), summary.count); + try std.testing.expectEqual(@as(u32, 68), summary.payload_len); +} + test "mergeModule — element section entries remapped and appended" { const allocator = std.testing.allocator; var host = try buildMergeHostModule(allocator); @@ -5881,6 +6167,39 @@ test "resolveCodeRelocations — global_index_leb function symbol rewrites funct try std.testing.expectEqual(defined.function.raw(), module.table_func_indices.items[0]); } +test "resolveCodeRelocations — global_index_leb data symbol rewrites data reference to memory address" { + const allocator = std.testing.allocator; + var module = Self.init(allocator); + defer module.deinit(); + + const segment_index: u32 = @intCast(module.data_segments.items.len); + try module.data_segments.append(allocator, .{ + .offset = 4096, + .data = try allocator.dupe(u8, &.{ 0, 0, 0, 0, 1, 2, 3, 4 }), + }); + try module.linking.symbol_table.append(allocator, .{ + .kind = .data, + .flags = 0, + .name = "static_value", + .index = segment_index, + .data_offset = 4, + .data_size = 4, + }); + + try module.code_bytes.append(allocator, Op.global_get); + try appendPaddedU32(allocator, &module.code_bytes, 999); + try module.reloc_code.entries.append(allocator, .{ .index = .{ + .type_id = .global_index_leb, + .offset = 1, + .symbol_index = 0, + } }); + + try module.resolveCodeRelocations(); + + try std.testing.expectEqual(Op.i32_const, module.code_bytes.items[0]); + try std.testing.expectEqual(@as(u32, 4100), decodePaddedU32(module.code_bytes.items[1..6])); +} + test "resolveCodeRelocations — function_offset_i32 resolves to function body offset" { const allocator = std.testing.allocator; var module = Self.init(allocator); @@ -6213,6 +6532,75 @@ test "mergeModule final link - resolves stack pointer import to global zero" { try std.testing.expectEqual(@as(u32, 0), decodePaddedU32(app.code_bytes.items[1..6])); } +test "addStaticDataExports defines forward data symbols used by code relocations" { + const allocator = std.testing.allocator; + var module = Self.init(allocator); + defer module.deinit(); + + _ = try module.addDataSegment(&.{ 0xaa, 0xbb, 0xcc }, 1); + const symbol = try module.addUndefinedDataSymbol("roc__static_value_0"); + + try module.code_bytes.append(allocator, Op.i32_const); + try appendPaddedI32(allocator, &module.code_bytes, 0); + try module.reloc_code.entries.append(allocator, .{ .offset = .{ + .type_id = .memory_addr_sleb, + .offset = 1, + .symbol_index = symbol.raw(), + .addend = 2, + } }); + + const exports = [_]StaticDataExport{.{ + .symbol_name = "roc__static_value_0", + .bytes = &.{ 9, 8, 7, 6 }, + .alignment = 4, + .is_global = false, + .is_exported = false, + .relocations = &.{}, + }}; + try module.addStaticDataExports(&exports); + + const sym = module.linking.symbol_table.items[symbol.raw()]; + try std.testing.expect(!sym.isUndefined()); + try std.testing.expectEqual(WasmLinking.SymKind.data, sym.kind); + try std.testing.expectEqualStrings("roc__static_value_0", sym.name.?); + try std.testing.expectEqual(@as(u32, 0), sym.data_offset); + try std.testing.expectEqual(@as(u32, 4), sym.data_size); + + try module.resolveCodeRelocations(); + const expected_addr: i32 = @intCast(module.data_segments.items[sym.index].offset + 2); + try std.testing.expectEqual(expected_addr, decodePaddedI32(module.code_bytes.items[1..6])); +} + +test "mergeModuleForObject resolves undefined static data symbols" { + const allocator = std.testing.allocator; + var app = Self.init(allocator); + defer app.deinit(); + + const symbol = try app.addUndefinedDataSymbol("roc__static_value_0"); + + const exports = [_]StaticDataExport{.{ + .symbol_name = "roc__static_value_0", + .bytes = &.{ 9, 8, 7, 6 }, + .alignment = 4, + .is_global = false, + .is_exported = false, + .relocations = &.{}, + }}; + var static_module = try Self.staticDataModule(allocator, &exports); + defer static_module.deinit(); + + var result = try app.mergeModuleForObject(&static_module); + defer result.deinit(); + + const sym = app.linking.symbol_table.items[symbol.raw()]; + try std.testing.expect(!sym.isUndefined()); + try std.testing.expectEqual(WasmLinking.SymKind.data, sym.kind); + try std.testing.expectEqualStrings("roc__static_value_0", sym.name.?); + try std.testing.expectEqual(@as(u32, 0), sym.data_offset); + try std.testing.expectEqual(@as(u32, 4), sym.data_size); + try app.verifyNoLinkObjectContract(); +} + test "encodeRelocatable roundtrip - preserves data symbols and data relocations" { const allocator = std.testing.allocator; var module = Self.init(allocator); diff --git a/src/base/LowLevel.zig b/src/base/LowLevel.zig index a1371662067..14c9ce1d23b 100644 --- a/src/base/LowLevel.zig +++ b/src/base/LowLevel.zig @@ -460,6 +460,11 @@ pub const LowLevel = enum(u16) { // Box operations box_box, box_unbox, + /// Box(T) -> Box(T): consume a box and return a unique box containing the + /// same payload. Reuses the allocation when uniqueness is known or the + /// runtime check succeeds; otherwise copies the payload into a fresh box, + /// retains nested payload children, and releases the consumed input. + box_prepare_update, erased_capture_load, // Compiler-internal pointer operations, introduced by the TRMC pass @@ -767,6 +772,8 @@ pub const LowLevel = enum(u16) { .box_unbox => RcEffect.retainsResultBorrowingArgs(argMask(&.{0})), + .box_prepare_update => RcEffect.runtimeUniqueness(argMask(&.{0})), + // The capture environment is read through the executing frame's // closure, not through an explicit refcounted argument, so the // result cannot name a lender to borrow from. diff --git a/src/build/roc/Builtin.roc b/src/build/roc/Builtin.roc index d690cb1768e..e858bef0b1d 100644 --- a/src/build/roc/Builtin.roc +++ b/src/build/roc/Builtin.roc @@ -1914,12 +1914,10 @@ Builtin :: [].{ Ok(Continue({ rest: HttpHeaderState.{ raw: line_parts.after } })) } else { Ok( - TryFieldCaseless( - { - name, - rest: HttpHeaderState.{ raw: value_start }, - }, - ), + TryFieldCaseless({ + name, + rest: HttpHeaderState.{ raw: value_start }, + }), ) } } @@ -2643,13 +2641,13 @@ Builtin :: [].{ # next seed, or `NoMore`. `custom` owns rebuilding the rest from the new seed, so the # seed type stays hidden inside the step closure and never appears in `Iter(item)`. custom : state, [Known(U64), Unknown], (state -> Try((item, state), [NoMore])) -> Iter(item) - custom = |seed, len_if_known, advance| { - len_if_known, - step: || - match advance(seed) { - Ok((item, next_seed)) => - One( - { + custom = |seed, len_if_known, advance| + iter_from_step( + len_if_known, + || + match advance(seed) { + Ok((item, next_seed)) => + One({ item, rest: Iter.custom( next_seed, @@ -2659,11 +2657,10 @@ Builtin :: [].{ }, advance, ), - }, - ) - Err(NoMore) => Done - }, - } + }) + Err(NoMore) => Done + }, + ) ## Iterator over `num` values from `start` up to but not including `end`. ## Returns an empty iterator if `start >= end`. This is what `start.. Try(num, [InvalidNumeral(Str)]), num.steps_between : num, num -> [Known(U64), Unknown], ] - exclusive_range = |start, end| { - len_if_known: start.steps_between(end), - step: || - if start < end { - One( - { + exclusive_range = |start, end| + iter_from_step( + start.steps_between(end), + || + if start < end { + One({ item: start, rest: match start.add_try(1) { Ok(next) => if next < end { @@ -2689,12 +2686,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator over `num` values from `start` up to and including `end`. ## Returns an empty iterator if `start > end`. This is what `start..=end` desugars to. @@ -2705,22 +2701,22 @@ Builtin :: [].{ num.from_numeral : Builtin.Num.Numeral -> Try(num, [InvalidNumeral(Str)]), num.steps_between : num, num -> [Known(U64), Unknown], ] - inclusive_range = |start, end| { - len_if_known: if start <= end { - match start.steps_between(end) { - Known(n) => match n.add_try(1) { - Ok(len) => Known(len) - Err(Overflow) => Unknown - } - Unknown => Unknown - } - } else { - Known(0) - }, - step: || + inclusive_range = |start, end| + iter_from_step( if start <= end { - One( - { + match start.steps_between(end) { + Known(n) => match n.add_try(1) { + Ok(len) => Known(len) + Err(Overflow) => Unknown + } + Unknown => Unknown + } + } else { + Known(0) + }, + || + if start <= end { + One({ item: start, rest: match start.add_try(1) { Ok(next) => if next <= end { @@ -2730,12 +2726,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) iter : Iter(item) -> Iter(item) iter = |self| self @@ -2746,8 +2741,25 @@ Builtin :: [].{ ## ``` single : item -> Iter(item) single = |item| { - len_if_known: Known(1), - step: || One({ item, rest: range_done() }), + # The post-item empty state is a `pending = Bool.False` value of this + # same iterator rather than the distinct `range_done` type, so the + # `rest` a consumer holds by value keeps one monomorphic type. + make = |pending| + iter_from_step( + if pending { + Known(1) + } else { + Known(0) + }, + || + if pending { + One({ item, rest: make(Bool.False) }) + } else { + Done + }, + ) + + make(Bool.True) } ## Returns an iterator that yields the given item first, followed by @@ -2759,13 +2771,14 @@ Builtin :: [].{ ## expect Iter.fold([2, 3].iter().prepended(1), [], |acc, item| acc.append(item)) == [1, 2, 3] ## ``` prepended : Iter(item), item -> Iter(item) - prepended = |rest, item| { - len_if_known: match rest.len_if_known { - Known(n) => Known(n + 1) - Unknown => Unknown - }, - step: || One({ item, rest }), - } + prepended = |rest, item| + iter_from_step( + match rest.len_if_known { + Known(n) => Known(n + 1) + Unknown => Unknown + }, + || One({ item, rest }), + ) ## Returns an iterator that yields all items from the first iterator, ## then all items from the second iterator. @@ -2774,23 +2787,38 @@ Builtin :: [].{ ## ``` concat : Iter(item), Iter(item) -> Iter(item) concat = |first, second| { - len_if_known: match first.len_if_known { - Known(first_len) => match second.len_if_known { - Known(second_len) => if second_len > 18446744073709551615 - first_len { - Unknown - } else { - Known(first_len + second_len) - } - Unknown => Unknown - } - Unknown => Unknown - }, - step: || - match Iter.next(first) { - Done => Iter.next(second) - Skip({ rest }) => Skip({ rest: Iter.concat(rest, second) }) - One({ item, rest }) => One({ item, rest: Iter.concat(rest, second) }) - }, + make = |remaining_first, remaining_second| + iter_from_step( + match remaining_first.len_if_known { + Known(first_len) => match remaining_second.len_if_known { + Known(second_len) => if second_len > 18446744073709551615 - first_len { + Unknown + } else { + Known(first_len + second_len) + } + Unknown => Unknown + } + Unknown => Unknown + }, + || + # Once `remaining_first` is exhausted it is kept (not swapped + # for `range_done()`) so `make`'s inner-iterator argument keeps + # a single monomorphic type for the whole chain. An exhausted + # iterator reports length 0 and its `next` stays `Done`, so this + # is length- and result-equivalent. + match Iter.next(remaining_first) { + Done => + match Iter.next(remaining_second) { + Done => Done + Skip({ rest }) => Skip({ rest: make(remaining_first, rest) }) + One({ item, rest }) => One({ item, rest: make(remaining_first, rest) }) + } + Skip({ rest }) => Skip({ rest: make(rest, remaining_second) }) + One({ item, rest }) => One({ item, rest: make(rest, remaining_second) }) + }, + ) + + make(first, second) } ## Returns an iterator that yields everything from the given iterator, @@ -2800,20 +2828,34 @@ Builtin :: [].{ ## ``` append : Iter(item), item -> Iter(item) append = |iterator, last| { - len_if_known: match iterator.len_if_known { - Known(len) => if len == 18446744073709551615 { - Unknown - } else { - Known(len + 1) - } - Unknown => Unknown - }, - step: || - match Iter.next(iterator) { - Done => One({ item: last, rest: range_done() }) - Skip({ rest }) => Skip({ rest: Iter.append(rest, last) }) - One({ item, rest }) => One({ item, rest: Iter.append(rest, last) }) - }, + make = |current, pending_last| + iter_from_step( + if pending_last { + match current.len_if_known { + Known(len) => + if len == 18446744073709551615 { + Unknown + } else { + Known(len + 1) + } + Unknown => Unknown + } + } else { + Known(0) + }, + || + if pending_last { + match Iter.next(current) { + Done => One({ item: last, rest: make(current, Bool.False) }) + Skip({ rest }) => Skip({ rest: make(rest, Bool.True) }) + One({ item, rest }) => One({ item, rest: make(rest, Bool.True) }) + } + } else { + Done + }, + ) + + make(iterator, Bool.True) } next : Iter(item) -> [One({ item : item, rest : Iter(item) }), Skip({ rest : Iter(item) }), Done] @@ -2822,56 +2864,51 @@ Builtin :: [].{ map : Iter(a), (a -> b) -> Iter(b) map = |iterator, transform| match iterator { - { len_if_known, step } => { - len_if_known, - step: || - match step() { - Done => Done - Skip({ rest }) => Skip({ rest: Iter.map(rest, transform) }) - One({ item, rest }) => One({ item: transform(item), rest: Iter.map(rest, transform) }) - }, + { len_if_known, .. } => + iter_from_step( + len_if_known, + || + match Iter.next(iterator) { + Done => Done + Skip({ rest }) => Skip({ rest: Iter.map(rest, transform) }) + One({ item, rest }) => One({ item: transform(item), rest: Iter.map(rest, transform) }) + }, + ) } - } keep_if : Iter(a), (a -> Bool) -> Iter(a) keep_if = |iterator, predicate| - match iterator { - { step, .. } => { - len_if_known: Unknown, - step: || { - match step() { - Done => Done - Skip({ rest }) => Skip({ rest: Iter.keep_if(rest, predicate) }) - One({ item, rest }) => - if predicate(item) { - One({ item, rest: Iter.keep_if(rest, predicate) }) - } else { - Skip({ rest: Iter.keep_if(rest, predicate) }) - } + iter_from_step( + Unknown, + || + match Iter.next(iterator) { + Done => Done + Skip({ rest }) => Skip({ rest: Iter.keep_if(rest, predicate) }) + One({ item, rest }) => + if predicate(item) { + One({ item, rest: Iter.keep_if(rest, predicate) }) + } else { + Skip({ rest: Iter.keep_if(rest, predicate) }) } - }, - } - } + }, + ) drop_if : Iter(a), (a -> Bool) -> Iter(a) drop_if = |iterator, predicate| - match iterator { - { step, .. } => { - len_if_known: Unknown, - step: || { - match step() { - Done => Done - Skip({ rest }) => Skip({ rest: Iter.drop_if(rest, predicate) }) - One({ item, rest }) => - if predicate(item) { - Skip({ rest: Iter.drop_if(rest, predicate) }) - } else { - One({ item, rest: Iter.drop_if(rest, predicate) }) - } + iter_from_step( + Unknown, + || + match Iter.next(iterator) { + Done => Done + Skip({ rest }) => Skip({ rest: Iter.drop_if(rest, predicate) }) + One({ item, rest }) => + if predicate(item) { + Skip({ rest: Iter.drop_if(rest, predicate) }) + } else { + One({ item, rest: Iter.drop_if(rest, predicate) }) } - }, - } - } + }, + ) fold : Iter(a), acc, (acc, a -> acc) -> acc fold = |iterator, acc, step| @@ -2910,12 +2947,10 @@ Builtin :: [].{ ## ``` take_first : Iter(item), U64 -> Iter(item) take_first = |iterator, n| - if n == 0 { - range_done() - } else { - match iterator { - { len_if_known, step } => { - len_if_known: match len_if_known { + match iterator { + { len_if_known, .. } => + iter_from_step( + match len_if_known { Known(len) => Known( if len < n { len @@ -2923,17 +2958,24 @@ Builtin :: [].{ n }, ) - Unknown => Unknown + Unknown => if n == 0 { + Known(0) + } else { + Unknown + } }, - step: || - match step() { - Done => Done - Skip({ rest }) => Skip({ rest: Iter.take_first(rest, n) }) - One({ item, rest }) => One({ item, rest: Iter.take_first(rest, n - 1) }) + || + if n == 0 { + Done + } else { + match Iter.next(iterator) { + Done => Done + Skip({ rest }) => Skip({ rest: Iter.take_first(rest, n) }) + One({ item, rest }) => One({ item, rest: Iter.take_first(rest, n - 1) }) + } }, - } + ) } - } ## Returns an iterator that skips the first `n` items of this iterator. ## If the source has `n` or fewer items, the result is empty. @@ -2944,12 +2986,10 @@ Builtin :: [].{ ## ``` drop_first : Iter(item), U64 -> Iter(item) drop_first = |iterator, n| - if n == 0 { - iterator - } else { - match iterator { - { len_if_known, step } => { - len_if_known: match len_if_known { + match iterator { + { len_if_known, .. } => + iter_from_step( + match len_if_known { Known(len) => Known( if len < n { 0 @@ -2959,15 +2999,19 @@ Builtin :: [].{ ) Unknown => Unknown }, - step: || - match step() { + || + match Iter.next(iterator) { Done => Done Skip({ rest }) => Skip({ rest: Iter.drop_first(rest, n) }) - One({ item: _, rest }) => Skip({ rest: Iter.drop_first(rest, n - 1) }) - }, - } + One({ item, rest }) => + if n == 0 { + One({ item, rest: Iter.drop_first(rest, 0) }) + } else { + Skip({ rest: Iter.drop_first(rest, n - 1) }) + } + }, + ) } - } ## Returns an iterator that yields the last `n` items of this iterator. ## If the source has fewer than `n` items, all of them are yielded. @@ -3027,30 +3071,39 @@ Builtin :: [].{ ## ``` step_by : Iter(item), U64 -> Iter(item) step_by = |iterator, n| - if n == 0 { - range_done() - } else { - match iterator { - { len_if_known, step } => { - len_if_known: match len_if_known { - Known(len) => Known( - if len == 0 { - 0 - } else { - (len - 1) / n + 1 - }, - ) - Unknown => Unknown + match iterator { + { len_if_known, .. } => + iter_from_step( + match len_if_known { + Known(len) => if n == 0 { + Known(0) + } else { + Known( + if len == 0 { + 0 + } else { + (len - 1) / n + 1 + }, + ) + } + Unknown => if n == 0 { + Known(0) + } else { + Unknown + } }, - step: || - match step() { - Done => Done - Skip({ rest }) => Skip({ rest: Iter.step_by(rest, n) }) - One({ item, rest }) => One({ item, rest: Iter.step_by(Iter.drop_first(rest, n - 1), n) }) + || + if n == 0 { + Done + } else { + match Iter.next(iterator) { + Done => Done + Skip({ rest }) => Skip({ rest: Iter.step_by(rest, n) }) + One({ item, rest }) => One({ item, rest: Iter.step_by(Iter.drop_first(rest, n - 1), n) }) + } }, - } + ) } - } ## Returns an iterator that yields this iterator's items in reverse order. ## @@ -3194,15 +3247,15 @@ Builtin :: [].{ make = |index| { len = List.len(list) - { - len_if_known: Known(len - index), - step: || + iter_from_step( + Known(len - index), + || if index == len { Done } else { One({ item: list_get_unsafe(list, index), rest: make(index + 1) }) }, - } + ) } make(0) @@ -3362,6 +3415,16 @@ Builtin :: [].{ list_append_unsafe(reserved, item) } + ## Add the `Ok` payload to the end of a list, or leave the list unchanged + ## if the value is `Err`. + ## ```roc + ## expect List.append_if_ok([1, 2], Ok(3)) == [1, 2, 3] + ## + ## expect List.append_if_ok([1, 2], Err(NotFound)) == [1, 2] + ## ``` + append_if_ok : List(a), Try(a, err) -> List(a) + append_if_ok = |list, maybe_item| list_append_if_ok(list, maybe_item) + ## Add a single element to the beginning of a list. ## ```roc ## expect [2, 3, 4].prepend(1) == [1, 2, 3, 4] @@ -4080,12 +4143,10 @@ Builtin :: [].{ where [a.is_eq : a, a -> Bool] split_first = |list, delim| match list->find_first_index(|x| x == delim) { - Ok(index) => Ok( - { - before: list.sublist({ start: 0, len: index }), - after: list.drop_first(index + 1), - }, - ) + Ok(index) => Ok({ + before: list.sublist({ start: 0, len: index }), + after: list.drop_first(index + 1), + }) Err(NotFound) => Err(NotFound) } @@ -4097,12 +4158,10 @@ Builtin :: [].{ where [a.is_eq : a, a -> Bool] split_last = |list, delim| match list->find_last_index(|x| x == delim) { - Ok(index) => Ok( - { - before: list.sublist({ start: 0, len: index }), - after: list.drop_first(index + 1), - }, - ) + Ok(index) => Ok({ + before: list.sublist({ start: 0, len: index }), + after: list.drop_first(index + 1), + }) Err(NotFound) => Err(NotFound) } @@ -4518,14 +4577,12 @@ Builtin :: [].{ with_capacity : U64 -> Dict(_k, _v) with_capacity = |requested| { metadata = dict_allocate_buckets_for_capacity(requested) - HashMap( - { - entries: List.with_capacity(requested), - buckets: metadata.buckets, - max_entries_before_grow: metadata.max_entries_before_grow, - shifts: metadata.shifts, - }, - ) + HashMap({ + entries: List.with_capacity(requested), + buckets: metadata.buckets, + max_entries_before_grow: metadata.max_entries_before_grow, + shifts: metadata.shifts, + }) } ## Returns the number of entries the dictionary can hold before growing. @@ -4563,14 +4620,12 @@ Builtin :: [].{ clear : Dict(k, v) -> Dict(k, v) clear = |dict| match dict { HashMap(data) => { - HashMap( - { - entries: List.take_first(data.entries, 0), - buckets: List.map(data.buckets, |_| dict_empty_bucket), - max_entries_before_grow: data.max_entries_before_grow, - shifts: data.shifts, - }, - ) + HashMap({ + entries: List.take_first(data.entries, 0), + buckets: List.map(data.buckets, |_| dict_empty_bucket), + max_entries_before_grow: data.max_entries_before_grow, + shifts: data.shifts, + }) } } @@ -5673,16 +5728,16 @@ Builtin :: [].{ ## expect Iter.fold(U8.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : U8, U8 -> Iter(U8) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - Known(U8.to_u64(end) - U8.to_u64(start) + 1) - }, - step: || - if start <= end { - One( - { + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + Known(U8.to_u64(end) - U8.to_u64(start) + 1) + }, + || + if start <= end { + One({ item: start, rest: match U8.add_try(start, 1) { Ok(next) => if next <= end { @@ -5692,12 +5747,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `U8` and ending with the other `U8` minus one. ## (Use [U8.to] instead to end with the other `U8` exactly, instead of minus one.) @@ -5710,16 +5764,16 @@ Builtin :: [].{ ## expect Iter.fold(U8.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : U8, U8 -> Iter(U8) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - Known(U8.to_u64(end) - U8.to_u64(start)) - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + Known(U8.to_u64(end) - U8.to_u64(start)) + }, + || + if start < end { + One({ item: start, rest: match U8.add_try(start, 1) { Ok(next) => if next < end { @@ -5729,12 +5783,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) # Conversions to signed integers (I8 is lossy, others are safe) @@ -6318,16 +6371,16 @@ Builtin :: [].{ ## expect Iter.fold(I8.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : I8, I8 -> Iter(I8) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - Known(I64.to_u64_wrap(I8.to_i64(end) - I8.to_i64(start) + 1)) - }, - step: || - if start <= end { - One( - { + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + Known(I64.to_u64_wrap(I8.to_i64(end) - I8.to_i64(start) + 1)) + }, + || + if start <= end { + One({ item: start, rest: match I8.add_try(start, 1) { Ok(next) => if next <= end { @@ -6337,12 +6390,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `I8` and ending with the other `I8` minus one. ## (Use [I8.to] instead to end with the other `I8` exactly, instead of minus one.) @@ -6355,16 +6407,16 @@ Builtin :: [].{ ## expect Iter.fold(I8.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : I8, I8 -> Iter(I8) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - Known(I64.to_u64_wrap(I8.to_i64(end) - I8.to_i64(start))) - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + Known(I64.to_u64_wrap(I8.to_i64(end) - I8.to_i64(start))) + }, + || + if start < end { + One({ item: start, rest: match I8.add_try(start, 1) { Ok(next) => if next < end { @@ -6374,12 +6426,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build an [I8] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -6984,16 +7035,16 @@ Builtin :: [].{ ## expect Iter.fold(U16.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : U16, U16 -> Iter(U16) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - Known(U16.to_u64(end) - U16.to_u64(start) + 1) - }, - step: || - if start <= end { - One( - { + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + Known(U16.to_u64(end) - U16.to_u64(start) + 1) + }, + || + if start <= end { + One({ item: start, rest: match U16.add_try(start, 1) { Ok(next) => if next <= end { @@ -7003,12 +7054,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `U16` and ending with the other `U16` minus one. ## (Use [U16.to] instead to end with the other `U16` exactly, instead of minus one.) @@ -7021,16 +7071,16 @@ Builtin :: [].{ ## expect Iter.fold(U16.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : U16, U16 -> Iter(U16) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - Known(U16.to_u64(end) - U16.to_u64(start)) - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + Known(U16.to_u64(end) - U16.to_u64(start)) + }, + || + if start < end { + One({ item: start, rest: match U16.add_try(start, 1) { Ok(next) => if next < end { @@ -7040,12 +7090,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build a [U16] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -7688,16 +7737,16 @@ Builtin :: [].{ ## expect Iter.fold(I16.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : I16, I16 -> Iter(I16) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - Known(I64.to_u64_wrap(I16.to_i64(end) - I16.to_i64(start) + 1)) - }, - step: || - if start <= end { - One( - { + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + Known(I64.to_u64_wrap(I16.to_i64(end) - I16.to_i64(start) + 1)) + }, + || + if start <= end { + One({ item: start, rest: match I16.add_try(start, 1) { Ok(next) => if next <= end { @@ -7707,12 +7756,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `I16` and ending with the other `I16` minus one. ## (Use [I16.to] instead to end with the other `I16` exactly, instead of minus one.) @@ -7725,16 +7773,16 @@ Builtin :: [].{ ## expect Iter.fold(I16.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : I16, I16 -> Iter(I16) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - Known(I64.to_u64_wrap(I16.to_i64(end) - I16.to_i64(start))) - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + Known(I64.to_u64_wrap(I16.to_i64(end) - I16.to_i64(start))) + }, + || + if start < end { + One({ item: start, rest: match I16.add_try(start, 1) { Ok(next) => if next < end { @@ -7744,12 +7792,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build an [I16] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -8369,16 +8416,16 @@ Builtin :: [].{ ## expect Iter.fold(U32.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : U32, U32 -> Iter(U32) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - Known(U32.to_u64(end) - U32.to_u64(start) + 1) - }, - step: || - if start <= end { - One( - { + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + Known(U32.to_u64(end) - U32.to_u64(start) + 1) + }, + || + if start <= end { + One({ item: start, rest: match U32.add_try(start, 1) { Ok(next) => if next <= end { @@ -8388,12 +8435,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `U32` and ending with the other `U32` minus one. ## (Use [U32.to] instead to end with the other `U32` exactly, instead of minus one.) @@ -8406,16 +8452,16 @@ Builtin :: [].{ ## expect Iter.fold(U32.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : U32, U32 -> Iter(U32) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - Known(U32.to_u64(end) - U32.to_u64(start)) - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + Known(U32.to_u64(end) - U32.to_u64(start)) + }, + || + if start < end { + One({ item: start, rest: match U32.add_try(start, 1) { Ok(next) => if next < end { @@ -8425,12 +8471,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build a [U32] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -9105,16 +9150,16 @@ Builtin :: [].{ ## expect Iter.fold(I32.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : I32, I32 -> Iter(I32) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - Known(I64.to_u64_wrap(I32.to_i64(end) - I32.to_i64(start) + 1)) - }, - step: || - if start <= end { - One( - { + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + Known(I64.to_u64_wrap(I32.to_i64(end) - I32.to_i64(start) + 1)) + }, + || + if start <= end { + One({ item: start, rest: match I32.add_try(start, 1) { Ok(next) => if next <= end { @@ -9124,12 +9169,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `I32` and ending with the other `I32` minus one. ## (Use [I32.to] instead to end with the other `I32` exactly, instead of minus one.) @@ -9142,16 +9186,16 @@ Builtin :: [].{ ## expect Iter.fold(I32.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : I32, I32 -> Iter(I32) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - Known(I64.to_u64_wrap(I32.to_i64(end) - I32.to_i64(start))) - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + Known(I64.to_u64_wrap(I32.to_i64(end) - I32.to_i64(start))) + }, + || + if start < end { + One({ item: start, rest: match I32.add_try(start, 1) { Ok(next) => if next < end { @@ -9161,12 +9205,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build an [I32] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -9803,19 +9846,19 @@ Builtin :: [].{ ## expect Iter.fold(U64.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : U64, U64 -> Iter(U64) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - match U64.add_try(end - start, 1) { - Ok(len) => Known(len) - Err(Overflow) => Unknown - } - }, - step: || - if start <= end { - One( - { + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + match U64.add_try(end - start, 1) { + Ok(len) => Known(len) + Err(Overflow) => Unknown + } + }, + || + if start <= end { + One({ item: start, rest: match U64.add_try(start, 1) { Ok(next) => if next <= end { @@ -9825,12 +9868,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `U64` and ending with the other `U64` minus one. ## (Use [U64.to] instead to end with the other `U64` exactly, instead of minus one.) @@ -9843,16 +9885,16 @@ Builtin :: [].{ ## expect Iter.fold(U64.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : U64, U64 -> Iter(U64) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - Known(end - start) - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + Known(end - start) + }, + || + if start < end { + One({ item: start, rest: match U64.add_try(start, 1) { Ok(next) => if next < end { @@ -9862,12 +9904,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build a [U64] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -10578,22 +10619,22 @@ Builtin :: [].{ ## expect Iter.fold(I64.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : I64, I64 -> Iter(I64) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - match I64.sub_try(end, start) { - Ok(diff) => match I64.add_try(diff, 1) { - Ok(d1) => Known(I64.to_u64_wrap(d1)) + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + match I64.sub_try(end, start) { + Ok(diff) => match I64.add_try(diff, 1) { + Ok(d1) => Known(I64.to_u64_wrap(d1)) + Err(Overflow) => Unknown + } Err(Overflow) => Unknown } - Err(Overflow) => Unknown - } - }, - step: || - if start <= end { - One( - { + }, + || + if start <= end { + One({ item: start, rest: match I64.add_try(start, 1) { Ok(next) => if next <= end { @@ -10603,12 +10644,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `I64` and ending with the other `I64` minus one. ## (Use [I64.to] instead to end with the other `I64` exactly, instead of minus one.) @@ -10621,19 +10661,19 @@ Builtin :: [].{ ## expect Iter.fold(I64.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : I64, I64 -> Iter(I64) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - match I64.sub_try(end, start) { - Ok(diff) => Known(I64.to_u64_wrap(diff)) - Err(Overflow) => Unknown - } - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + match I64.sub_try(end, start) { + Ok(diff) => Known(I64.to_u64_wrap(diff)) + Err(Overflow) => Unknown + } + }, + || + if start < end { + One({ item: start, rest: match I64.add_try(start, 1) { Ok(next) => if next < end { @@ -10643,12 +10683,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build an [I64] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -11301,22 +11340,22 @@ Builtin :: [].{ ## expect Iter.fold(U128.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : U128, U128 -> Iter(U128) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - match U128.to_u64_try(end - start) { - Ok(diff_u64) => match U64.add_try(diff_u64, 1) { - Ok(len) => Known(len) - Err(Overflow) => Unknown + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + match U128.to_u64_try(end - start) { + Ok(diff_u64) => match U64.add_try(diff_u64, 1) { + Ok(len) => Known(len) + Err(Overflow) => Unknown + } + Err(OutOfRange) => Unknown } - Err(OutOfRange) => Unknown - } - }, - step: || - if start <= end { - One( - { + }, + || + if start <= end { + One({ item: start, rest: match U128.add_try(start, 1) { Ok(next) => if next <= end { @@ -11326,12 +11365,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `U128` and ending with the other `U128` minus one. ## (Use [U128.to] instead to end with the other `U128` exactly, instead of minus one.) @@ -11344,19 +11382,19 @@ Builtin :: [].{ ## expect Iter.fold(U128.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : U128, U128 -> Iter(U128) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - match U128.to_u64_try(end - start) { - Ok(len) => Known(len) - Err(OutOfRange) => Unknown - } - }, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + match U128.to_u64_try(end - start) { + Ok(len) => Known(len) + Err(OutOfRange) => Unknown + } + }, + || + if start < end { + One({ item: start, rest: match U128.add_try(start, 1) { Ok(next) => if next < end { @@ -11366,12 +11404,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build a [U128] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -12118,25 +12155,25 @@ Builtin :: [].{ ## expect Iter.fold(I128.to(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` to : I128, I128 -> Iter(I128) - to = |start, end| { - len_if_known: if start > end { - Known(0) - } else { - match I128.sub_try(end, start) { - Ok(diff) => match I128.to_u64_try(diff) { - Ok(diff_u64) => match U64.add_try(diff_u64, 1) { - Ok(len) => Known(len) - Err(Overflow) => Unknown + to = |start, end| + iter_from_step( + if start > end { + Known(0) + } else { + match I128.sub_try(end, start) { + Ok(diff) => match I128.to_u64_try(diff) { + Ok(diff_u64) => match U64.add_try(diff_u64, 1) { + Ok(len) => Known(len) + Err(Overflow) => Unknown + } + Err(OutOfRange) => Unknown } - Err(OutOfRange) => Unknown + Err(Overflow) => Unknown } - Err(Overflow) => Unknown - } - }, - step: || - if start <= end { - One( - { + }, + || + if start <= end { + One({ item: start, rest: match I128.add_try(start, 1) { Ok(next) => if next <= end { @@ -12146,12 +12183,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of integers beginning with this `I128` and ending with the other `I128` minus one. ## (Use [I128.to] instead to end with the other `I128` exactly, instead of minus one.) @@ -12164,22 +12200,22 @@ Builtin :: [].{ ## expect Iter.fold(I128.until(5, 2), [], |acc, item| acc.append(item)) == [] ## ``` until : I128, I128 -> Iter(I128) - until = |start, end| { - len_if_known: if start >= end { - Known(0) - } else { - match I128.sub_try(end, start) { - Ok(diff) => match I128.to_u64_try(diff) { - Ok(len) => Known(len) - Err(OutOfRange) => Unknown + until = |start, end| + iter_from_step( + if start >= end { + Known(0) + } else { + match I128.sub_try(end, start) { + Ok(diff) => match I128.to_u64_try(diff) { + Ok(len) => Known(len) + Err(OutOfRange) => Unknown + } + Err(Overflow) => Unknown } - Err(Overflow) => Unknown - } - }, - step: || - if start < end { - One( - { + }, + || + if start < end { + One({ item: start, rest: match I128.add_try(start, 1) { Ok(next) => if next < end { @@ -12189,12 +12225,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Build an [I128] from a list of base-10 digits, most significant first. ## Each element of the list must be a digit in the range `0` to `9`. @@ -13314,12 +13349,12 @@ Builtin :: [].{ ## expect Iter.fold(Dec.to(5.0, 2.0), [], |acc, item| acc.append(item)) == [] ## ``` to : Dec, Dec -> Iter(Dec) - to = |start, end| { - len_if_known: Unknown, - step: || - if start <= end { - One( - { + to = |start, end| + iter_from_step( + Unknown, + || + if start <= end { + One({ item: start, rest: match Dec.add_try(start, 1.0) { Ok(next) => if next <= end { @@ -13329,12 +13364,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Iterator of decimals beginning with this `Dec` and ending with the ## other `Dec` minus one, stepping by `1.0`. (Use [Dec.to] instead to @@ -13349,12 +13383,12 @@ Builtin :: [].{ ## expect Iter.fold(Dec.until(5.0, 2.0), [], |acc, item| acc.append(item)) == [] ## ``` until : Dec, Dec -> Iter(Dec) - until = |start, end| { - len_if_known: Unknown, - step: || - if start < end { - One( - { + until = |start, end| + iter_from_step( + Unknown, + || + if start < end { + One({ item: start, rest: match Dec.add_try(start, 1.0) { Ok(next) => if next < end { @@ -13364,12 +13398,11 @@ Builtin :: [].{ } Err(Overflow) => range_done() }, - }, - ) - } else { - Done - }, - } + }) + } else { + Done + }, + ) ## Encode a Dec using a format that provides encode_dec encode : Dec, fmt -> Try(encoded, err) @@ -15342,12 +15375,10 @@ dict_find = |data, key| { Missing({ bucket_index: 0, dist_and_fingerprint: 0 }) } else { hash = dict_hash_key(key) - Missing( - { - bucket_index: dict_bucket_index_from_hash(hash, data.shifts), - dist_and_fingerprint: dict_dist_and_fingerprint_from_hash(hash), - }, - ) + Missing({ + bucket_index: dict_bucket_index_from_hash(hash, data.shifts), + dist_and_fingerprint: dict_dist_and_fingerprint_from_hash(hash), + }) } } else if List.is_empty(data.buckets) { crash "Dict invariant violated: entries without buckets" @@ -16370,6 +16401,13 @@ signed_div_ceil_try = |lowest, highest, zero, one, neg_one, a, b| } } +list_append_if_ok : List(a), Try(a, err) -> List(a) +list_append_if_ok = |list, maybe_item| + match maybe_item { + Ok(item) => List.append(list, item) + Err(_) => list + } + unsigned_minus_saturated : item, item, item -> item where [item.is_lt : item, item -> Bool, item.minus : item, item -> item] unsigned_minus_saturated = |zero, a, b| @@ -16529,12 +16567,18 @@ signed_is_multiple_of = |zero, neg_one, value, divisor| numeric_compare : item, item -> [LT, EQ, GT] -range_done : () -> Iter(item) -range_done = || { - len_if_known: Known(0), - step: || Done, +iter_from_step : [Known(U64), Unknown], (() -> [One({ item : item, rest : Iter(item) }), Skip({ rest : Iter(item) }), Done]) -> Iter(item) +iter_from_step = |len_if_known, step| { + len_if_known, + step, } +range_done : () -> Iter(item) +range_done = || iter_from_step( + Known(0), + || Done, +) + # Implemented by the compiler, does not perform bounds checks list_get_unsafe : List(item), U64 -> item diff --git a/src/build/zig_binaryen.cpp b/src/build/zig_binaryen.cpp new file mode 100644 index 00000000000..7b51563ec04 --- /dev/null +++ b/src/build/zig_binaryen.cpp @@ -0,0 +1,134 @@ +// Keep Binaryen C++ API use contained behind a C ABI boundary. + +#include "binaryen-c.h" + +#include +#include +#include + +extern "C" { + +typedef struct RocBinaryenOptimizeConfig { + int optimize_level; + int shrink_level; + uint8_t zero_filled_memory; + uint8_t debug_info; + uint8_t strip_debug; + uint8_t strip_producers; + uint8_t strip_target_features; + uint8_t validate; +} RocBinaryenOptimizeConfig; + +typedef struct RocBinaryenBuffer { + uint8_t* ptr; + size_t len; +} RocBinaryenBuffer; + +enum RocBinaryenStatus { + RocBinaryenStatusOk = 0, + RocBinaryenStatusInvalidArguments = 1, + RocBinaryenStatusReadFailed = 2, + RocBinaryenStatusValidateFailed = 3, + RocBinaryenStatusWriteFailed = 4, +}; + +} + +namespace { + +std::mutex binaryen_mutex; + +struct PassOptionsGuard { + int optimize_level; + int shrink_level; + bool zero_filled_memory; + bool debug_info; + + PassOptionsGuard() + : optimize_level(BinaryenGetOptimizeLevel()), + shrink_level(BinaryenGetShrinkLevel()), + zero_filled_memory(BinaryenGetZeroFilledMemory()), + debug_info(BinaryenGetDebugInfo()) {} + + ~PassOptionsGuard() { + BinaryenSetOptimizeLevel(optimize_level); + BinaryenSetShrinkLevel(shrink_level); + BinaryenSetZeroFilledMemory(zero_filled_memory); + BinaryenSetDebugInfo(debug_info); + } +}; + +} // namespace + +extern "C" int RocBinaryenOptimizeWasm( + const uint8_t* input, + size_t input_len, + RocBinaryenOptimizeConfig config, + RocBinaryenBuffer* output +) { + if (input == nullptr || input_len == 0 || output == nullptr) { + return RocBinaryenStatusInvalidArguments; + } + + output->ptr = nullptr; + output->len = 0; + + std::lock_guard lock(binaryen_mutex); + PassOptionsGuard guard; + + BinaryenSetOptimizeLevel(config.optimize_level); + BinaryenSetShrinkLevel(config.shrink_level); + BinaryenSetZeroFilledMemory(config.zero_filled_memory != 0); + BinaryenSetDebugInfo(config.debug_info != 0); + + BinaryenModuleRef module = BinaryenModuleReadWithFeatures( + reinterpret_cast(const_cast(input)), + input_len, + BinaryenFeatureAll() + ); + if (module == nullptr) { + return RocBinaryenStatusReadFailed; + } + + BinaryenModuleOptimize(module); + + const char* strip_passes[4]; + BinaryenIndex strip_count = 0; + if (config.strip_debug != 0 && config.debug_info == 0) { + strip_passes[strip_count++] = "strip-debug"; + strip_passes[strip_count++] = "strip-dwarf"; + } + if (config.strip_producers != 0) { + strip_passes[strip_count++] = "strip-producers"; + } + if (config.strip_target_features != 0) { + strip_passes[strip_count++] = "strip-target-features"; + } + if (strip_count != 0) { + BinaryenModuleRunPasses(module, strip_passes, strip_count); + } + + if (config.validate != 0 && !BinaryenModuleValidate(module)) { + BinaryenModuleDispose(module); + return RocBinaryenStatusValidateFailed; + } + + BinaryenModuleAllocateAndWriteResult result = + BinaryenModuleAllocateAndWrite(module, nullptr); + BinaryenModuleDispose(module); + + if (result.sourceMap != nullptr) { + free(result.sourceMap); + } + if (result.binary == nullptr && result.binaryBytes != 0) { + return RocBinaryenStatusWriteFailed; + } + + output->ptr = static_cast(result.binary); + output->len = result.binaryBytes; + return RocBinaryenStatusOk; +} + +extern "C" void RocBinaryenFree(void* ptr) { + free(ptr); +} diff --git a/src/build/zig_llvm.cpp b/src/build/zig_llvm.cpp index f02f44c37cb..5d2363f58d0 100644 --- a/src/build/zig_llvm.cpp +++ b/src/build/zig_llvm.cpp @@ -476,7 +476,7 @@ ZIG_EXTERN_C bool ZigLLVMTargetMachineEmitToFile(LLVMTargetMachineRef targ_machi // Optimization phase module_pm.run(llvm_module, module_am); - if (options->no_target_libcalls) { + if (options->lower_memory_intrinsics_to_loops) { FunctionPassManager lower_mem_pm; lower_mem_pm.addPass(LowerMemoryIntrinsicsPass(target_machine.getTargetIRAnalysis())); diff --git a/src/build/zig_llvm.h b/src/build/zig_llvm.h index 225829f6ff0..e5eb5fa3311 100644 --- a/src/build/zig_llvm.h +++ b/src/build/zig_llvm.h @@ -86,6 +86,7 @@ struct ZigLLVMEmitOptions { const char *bitcode_filename; ZigLLVMCoverageOptions coverage; bool no_target_libcalls; + bool lower_memory_intrinsics_to_loops; }; // synchronize with llvm/include/Object/Archive.h::Object::Archive::Kind diff --git a/src/builtins/dev_wrappers.zig b/src/builtins/dev_wrappers.zig index 3fee841033f..026e2f26f1a 100644 --- a/src/builtins/dev_wrappers.zig +++ b/src/builtins/dev_wrappers.zig @@ -1096,6 +1096,45 @@ pub fn roc_builtins_list_free_with( } } +/// Prepare a boxed payload allocation for replacement. +/// +/// The returned pointer is unique for the caller. It is either the original +/// payload pointer or a fresh payload copy whose nested refcounted children have +/// been retained. +pub fn roc_builtins_box_prepare_update( + payload_ptr: ?[*]u8, + payload_size: usize, + payload_alignment: u32, + payload_has_refcounted_children: bool, + payload_incref: ?RcIncFn, + payload_decref: ?RcDropFn, + update_mode: utils.UpdateMode, + roc_ops: *RocOps, +) callconv(.c) ?[*]u8 { + if (payload_size == 0 or payload_ptr == null) { + return payload_ptr; + } + + if (update_mode == .InPlace or utils.isUnique(payload_ptr, roc_ops)) { + return payload_ptr; + } + + const fresh = allocateWithRefcountC( + payload_size, + payload_alignment, + payload_has_refcounted_children, + roc_ops, + ); + @memcpy(fresh[0..payload_size], payload_ptr.?[0..payload_size]); + + if (payload_has_refcounted_children) { + (payload_incref orelse unreachable)(fresh, 1, roc_ops); + } + + roc_builtins_box_decref_with(payload_ptr, payload_alignment, payload_decref, roc_ops); + return fresh; +} + /// Decref a boxed payload and optionally run payload teardown when unique. pub fn roc_builtins_box_decref_with( payload_ptr: ?[*]u8, @@ -1185,6 +1224,28 @@ pub fn roc_builtins_erased_callable_decref_single_thread(payload_ptr: ?[*]u8, ro ); } +/// Repack a consumed boxed erased callable allocation for a same-layout +/// replacement, reusing the old allocation when uniqueness permits. +pub fn roc_builtins_erased_callable_repack( + reuse: ?[*]u8, + callable_fn_ptr: erased_callable.CallableFnPtr, + on_drop: ?erased_callable.OnDropFn, + capture_src: ?[*]const u8, + capture_size: usize, + update_mode: utils.UpdateMode, + roc_ops: *RocOps, +) callconv(.c) [*]u8 { + return erased_callable.repack( + reuse, + callable_fn_ptr, + on_drop, + capture_src, + capture_size, + update_mode, + roc_ops, + ); +} + /// Free a boxed erased callable payload pointer, running the payload's /// `on_drop` callback unconditionally first. pub fn roc_builtins_erased_callable_free(payload_ptr: ?[*]u8, roc_ops: *RocOps) callconv(.c) void { diff --git a/src/builtins/erased_callable.zig b/src/builtins/erased_callable.zig index 039508a0dbf..1507f796d5c 100644 --- a/src/builtins/erased_callable.zig +++ b/src/builtins/erased_callable.zig @@ -109,6 +109,50 @@ pub fn allocate( return data_ptr; } +/// Prepare an erased callable allocation for a same-layout repack and write +/// the new callable header/capture into it. +/// +/// `reuse` may be reused only when the caller has already established that the +/// old allocation has the same payload size and alignment as the new callable. +/// If `update_mode` is `.InPlace`, the caller has proven uniqueness. Otherwise +/// this helper performs the runtime uniqueness check and allocates a fresh +/// callable when the old value is shared. +pub fn repack( + reuse: ?[*]u8, + callable_fn_ptr: CallableFnPtr, + on_drop: ?OnDropFn, + capture_src: ?[*]const u8, + capture_size: usize, + update_mode: utils.UpdateMode, + roc_ops: *RocOps, +) [*]u8 { + const data_ptr = if (reuse) |old_ptr| blk: { + if (update_mode == .InPlace or utils.isUnique(old_ptr, roc_ops)) { + const old_payload = payloadPtr(old_ptr); + if (old_payload.on_drop) |old_on_drop| { + old_on_drop(capturePtr(old_ptr), roc_ops); + } + break :blk old_ptr; + } + + const fresh = allocate(callable_fn_ptr, on_drop, capture_size, roc_ops); + decref(old_ptr, roc_ops); + break :blk fresh; + } else allocate(callable_fn_ptr, on_drop, capture_size, roc_ops); + + payloadPtr(data_ptr).* = .{ + .callable_fn_ptr = callable_fn_ptr, + .on_drop = on_drop, + }; + + if (capture_size > 0) { + const src = capture_src orelse unreachable; + @memcpy(capturePtr(data_ptr)[0..capture_size], src[0..capture_size]); + } + + return data_ptr; +} + /// Interpret a boxed-erased-callable data pointer as its payload header. pub fn payloadPtr(data_ptr: [*]u8) *Payload { return @ptrCast(@alignCast(data_ptr)); @@ -174,3 +218,92 @@ pub fn free(data_ptr: ?[*]u8, roc_ops: *RocOps) callconv(.c) void { } utils.freeDataPtrC(data_ptr, payload_alignment, allocation_has_refcounted_children, roc_ops); } + +const RepackTestState = struct { + var old_drop_count: usize = 0; + var new_drop_count: usize = 0; + var old_drop_first_byte: u8 = 0; + + fn reset() void { + old_drop_count = 0; + new_drop_count = 0; + old_drop_first_byte = 0; + } + + fn callable(_: *RocOps, _: ?[*]u8, _: ?[*]const u8, _: ?[*]u8) callconv(.c) void {} + + fn oldDrop(capture: ?[*]u8, _: *RocOps) callconv(.c) void { + old_drop_count += 1; + old_drop_first_byte = if (capture) |ptr| ptr[0] else 0; + } + + fn newDrop(_: ?[*]u8, _: *RocOps) callconv(.c) void { + new_drop_count += 1; + } +}; + +test "erased callable repack reuses unique allocation and drops old capture" { + RepackTestState.reset(); + var test_env = utils.TestEnv.init(std.testing.allocator); + defer test_env.deinit(); + const ops = test_env.getOps(); + + const old = allocate(&RepackTestState.callable, &RepackTestState.oldDrop, 4, ops); + capturePtr(old)[0..4].* = .{ 11, 12, 13, 14 }; + + var new_capture = [_]u8{ 21, 22, 23, 24 }; + const result = repack( + old, + &RepackTestState.callable, + &RepackTestState.newDrop, + &new_capture, + new_capture.len, + .InPlace, + ops, + ); + + try std.testing.expectEqual(@intFromPtr(old), @intFromPtr(result)); + try std.testing.expectEqual(@as(usize, 1), RepackTestState.old_drop_count); + try std.testing.expectEqual(@as(u8, 11), RepackTestState.old_drop_first_byte); + try std.testing.expectEqualSlices(u8, &new_capture, capturePtr(result)[0..new_capture.len]); + + free(result, ops); + try std.testing.expectEqual(@as(usize, 1), RepackTestState.new_drop_count); + try std.testing.expectEqual(@as(usize, 0), test_env.getAllocationCount()); +} + +test "erased callable repack copies shared allocation and preserves old capture" { + RepackTestState.reset(); + var test_env = utils.TestEnv.init(std.testing.allocator); + defer test_env.deinit(); + const ops = test_env.getOps(); + + const old = allocate(&RepackTestState.callable, &RepackTestState.oldDrop, 4, ops); + capturePtr(old)[0..4].* = .{ 31, 32, 33, 34 }; + incref(old, 1, ops); + + var new_capture = [_]u8{ 41, 42, 43, 44 }; + const result = repack( + old, + &RepackTestState.callable, + &RepackTestState.newDrop, + &new_capture, + new_capture.len, + .Immutable, + ops, + ); + + try std.testing.expect(@intFromPtr(old) != @intFromPtr(result)); + try std.testing.expectEqual(@as(usize, 0), RepackTestState.old_drop_count); + try std.testing.expectEqual(@as(usize, 2), test_env.getAllocationCount()); + try std.testing.expectEqualSlices(u8, &new_capture, capturePtr(result)[0..new_capture.len]); + try std.testing.expectEqualSlices(u8, &.{ 31, 32, 33, 34 }, capturePtr(old)[0..4]); + + decref(old, ops); + try std.testing.expectEqual(@as(usize, 1), RepackTestState.old_drop_count); + try std.testing.expectEqual(@as(u8, 31), RepackTestState.old_drop_first_byte); + + free(result, ops); + try std.testing.expectEqual(@as(usize, 1), RepackTestState.new_drop_count); + try std.testing.expectEqual(@as(usize, 0), test_env.getAllocationCount()); +} diff --git a/src/builtins/static_lib.zig b/src/builtins/static_lib.zig index 1afd06dc079..2a5aef1b7ac 100644 --- a/src/builtins/static_lib.zig +++ b/src/builtins/static_lib.zig @@ -94,12 +94,14 @@ comptime { @export(&dw.roc_builtins_list_decref_with_single_thread, .{ .name = "roc_builtins_list_decref_with_single_thread" }); @export(&dw.roc_builtins_list_free_flat_list, .{ .name = "roc_builtins_list_free_flat_list" }); @export(&dw.roc_builtins_list_free_with, .{ .name = "roc_builtins_list_free_with" }); + @export(&dw.roc_builtins_box_prepare_update, .{ .name = "roc_builtins_box_prepare_update" }); @export(&dw.roc_builtins_box_decref_with, .{ .name = "roc_builtins_box_decref_with" }); @export(&dw.roc_builtins_box_decref_with_single_thread, .{ .name = "roc_builtins_box_decref_with_single_thread" }); @export(&dw.roc_builtins_box_free_with, .{ .name = "roc_builtins_box_free_with" }); @export(&dw.roc_builtins_erased_callable_incref, .{ .name = "roc_builtins_erased_callable_incref" }); @export(&dw.roc_builtins_erased_callable_decref, .{ .name = "roc_builtins_erased_callable_decref" }); @export(&dw.roc_builtins_erased_callable_decref_single_thread, .{ .name = "roc_builtins_erased_callable_decref_single_thread" }); + @export(&dw.roc_builtins_erased_callable_repack, .{ .name = "roc_builtins_erased_callable_repack" }); @export(&dw.roc_builtins_erased_callable_free, .{ .name = "roc_builtins_erased_callable_free" }); @export(&dw.roc_builtins_allocate_with_refcount, .{ .name = "roc_builtins_allocate_with_refcount" }); @export(&dw.roc_builtins_incref_data_ptr, .{ .name = "roc_builtins_incref_data_ptr" }); diff --git a/src/builtins/static_lib_core.zig b/src/builtins/static_lib_core.zig index 9e958dec287..2a5e3d67d2c 100644 --- a/src/builtins/static_lib_core.zig +++ b/src/builtins/static_lib_core.zig @@ -95,12 +95,14 @@ comptime { @export(&dw.roc_builtins_list_list_eq, .{ .name = "roc_builtins_list_list_eq" }); @export(&dw.roc_builtins_list_reverse, .{ .name = "roc_builtins_list_reverse" }); + @export(&dw.roc_builtins_box_prepare_update, .{ .name = "roc_builtins_box_prepare_update" }); @export(&dw.roc_builtins_box_decref_with, .{ .name = "roc_builtins_box_decref_with" }); @export(&dw.roc_builtins_box_decref_with_single_thread, .{ .name = "roc_builtins_box_decref_with_single_thread" }); @export(&dw.roc_builtins_box_free_with, .{ .name = "roc_builtins_box_free_with" }); @export(&dw.roc_builtins_erased_callable_incref, .{ .name = "roc_builtins_erased_callable_incref" }); @export(&dw.roc_builtins_erased_callable_decref, .{ .name = "roc_builtins_erased_callable_decref" }); @export(&dw.roc_builtins_erased_callable_decref_single_thread, .{ .name = "roc_builtins_erased_callable_decref_single_thread" }); + @export(&dw.roc_builtins_erased_callable_repack, .{ .name = "roc_builtins_erased_callable_repack" }); @export(&dw.roc_builtins_erased_callable_free, .{ .name = "roc_builtins_erased_callable_free" }); @export(&dw.roc_builtins_allocate_with_refcount, .{ .name = "roc_builtins_allocate_with_refcount" }); @export(&dw.roc_builtins_incref_data_ptr, .{ .name = "roc_builtins_incref_data_ptr" }); diff --git a/src/canonicalize/CIR.zig b/src/canonicalize/CIR.zig index fff4e68c762..398f7d97216 100644 --- a/src/canonicalize/CIR.zig +++ b/src/canonicalize/CIR.zig @@ -408,6 +408,7 @@ pub const WhereClause = union(enum) { method_name: base.Ident.Idx, args: TypeAnno.Span, ret: TypeAnno.Idx, + effectful: bool, }, w_alias: struct { var_: TypeAnno.Idx, @@ -433,6 +434,7 @@ pub const WhereClause = union(enum) { const method_name_str = cir.getIdent(method.method_name); try tree.pushStringPair("name", method_name_str); + try tree.pushBoolPair("effectful", method.effectful); const attrs = tree.beginNode(); diff --git a/src/canonicalize/Can.zig b/src/canonicalize/Can.zig index 47f5c7121bf..9ac90cc5e2c 100644 --- a/src/canonicalize/Can.zig +++ b/src/canonicalize/Can.zig @@ -20155,6 +20155,7 @@ fn canonicalizeWhereClause(self: *Self, ast_where_idx: AST.WhereClause.Idx, type .method_name = method_ident, .args = args_span, .ret = ret, + .effectful = mm.effectful, } }, region); }, .mod_alias => |ma| { diff --git a/src/canonicalize/Node.zig b/src/canonicalize/Node.zig index 660d80a42f8..f181b8012f4 100644 --- a/src/canonicalize/Node.zig +++ b/src/canonicalize/Node.zig @@ -1001,6 +1001,7 @@ pub const Payload = extern union { var_idx: u32, name: u32, args_ret_idx: u32, // Index into span_with_node_data: (args.start, args.len, ret) + effectful: u32, }; pub const WhereMalformed = extern struct { diff --git a/src/canonicalize/NodeStore.zig b/src/canonicalize/NodeStore.zig index 591ad17dd90..4d1fbcaa755 100644 --- a/src/canonicalize/NodeStore.zig +++ b/src/canonicalize/NodeStore.zig @@ -1633,6 +1633,7 @@ pub fn getWhereClause(store: *const NodeStore, whereClause: CIR.WhereClause.Idx) .method_name = method_name, .args = .{ .span = .{ .start = args_ret.start, .len = args_ret.len } }, .ret = @enumFromInt(args_ret.node), + .effectful = p.effectful != 0, } }; }, .where_alias => { @@ -2916,6 +2917,7 @@ pub fn addWhereClause(store: *NodeStore, whereClause: CIR.WhereClause, region: b .var_idx = @intFromEnum(where_method.var_), .name = @bitCast(where_method.method_name), .args_ret_idx = args_ret_idx, + .effectful = @intFromBool(where_method.effectful), } }); }, .w_alias => |mod_alias| { diff --git a/src/check/Check.zig b/src/check/Check.zig index 5435b3636b5..a7365c37e65 100644 --- a/src/check/Check.zig +++ b/src/check/Check.zig @@ -2859,14 +2859,14 @@ fn exprCanBeHoistedRoot(self: *Self, expr: CIR.Expr.Idx) bool { .e_hosted_lambda, => false, .e_str => |str| self.stringHasInterpolation(str.span), + .e_method_call => |call| !self.methodNameIs(call.method_name, "iter"), + .e_dispatch_call => |call| !self.methodNameIs(call.method_name, "iter"), .e_list, .e_tuple, .e_block, .e_match, .e_if, .e_call, - .e_method_call, - .e_dispatch_call, .e_record, .e_tag, .e_nominal, @@ -2934,14 +2934,14 @@ fn exprCanCoverHoistedChildren(self: *Self, expr: CIR.Expr.Idx) bool { .e_run_low_level, => false, .e_str => |str| self.stringHasInterpolation(str.span), + .e_method_call => |call| !self.methodNameIs(call.method_name, "iter"), + .e_dispatch_call => |call| !self.methodNameIs(call.method_name, "iter"), .e_list, .e_tuple, .e_block, .e_match, .e_if, .e_call, - .e_method_call, - .e_dispatch_call, .e_record, .e_tag, .e_nominal, @@ -2967,11 +2967,11 @@ fn exprCanBeHoistedBindingRoot(self: *Self, expr: CIR.Expr.Idx) bool { return switch (self.cir.store.getExpr(expr)) { .e_lookup_local => true, .e_call, - .e_method_call, .e_type_method_call, .e_type_dispatch_call, - .e_dispatch_call, => true, + .e_method_call => |call| !self.methodNameIs(call.method_name, "iter"), + .e_dispatch_call => |call| !self.methodNameIs(call.method_name, "iter"), .e_for, .e_run_low_level, .e_lookup_required, @@ -3027,6 +3027,10 @@ fn exprCanBeHoistedBindingRoot(self: *Self, expr: CIR.Expr.Idx) bool { }; } +fn methodNameIs(self: *const Self, method_name: Ident.Idx, comptime text: []const u8) bool { + return Ident.textEql(self.cir.getIdentText(method_name), text); +} + fn stringHasInterpolation(self: *Self, span: CIR.Expr.Span) bool { for (self.cir.store.sliceExpr(span)) |segment| { if (self.cir.store.getExpr(segment) != .e_str_segment) return true; @@ -13806,6 +13810,7 @@ fn checkBlockStatements(self: *Self, statements: CIR.Statement.Span, env: *Env, self.markCurrentHoistObservableEffect(); statement_blocks_later_hoists = true; const for_region = self.cir.store.getStatementRegion(stmt_idx); + const for_expected = if (blocks_later_hoists) base_statement_expected else statement_expected; does_fx = try self.checkIteratorForLoop( ModuleEnv.nodeIdxFrom(stmt_idx), for_stmt.patt, @@ -13813,7 +13818,7 @@ fn checkBlockStatements(self: *Self, statements: CIR.Statement.Span, env: *Env, for_stmt.body, env, for_region, - statement_expected, + for_expected, ) or does_fx; const empty_rec = try self.freshFromContent(.{ .structure = .empty_record }, env, for_region); _ = try self.unify(stmt_var, empty_rec, env); diff --git a/src/check/checked_artifact.zig b/src/check/checked_artifact.zig index a5f6a7a1039..8bedf824281 100644 --- a/src/check/checked_artifact.zig +++ b/src/check/checked_artifact.zig @@ -22542,13 +22542,16 @@ pub const ConstRef = struct { source_scheme: canonical.CanonicalTypeSchemeKey, }; +/// Public checked constant locator declaration. +pub const ConstLocator = ConstRef; + /// Return the checked module id that owns a compile-time constant. pub fn constModuleId(ref: ConstRef) ModuleId { return ref.artifact; } /// Public `ConstOwner` declaration. -pub const ConstOwner = union(enum) { +pub const ConstOwner = union(enum(u8)) { top_level_binding: ConstTopLevelOwner, hoisted_expr: ConstHoistedOwner, }; @@ -26024,7 +26027,7 @@ pub const CheckedModuleArtifact = struct { /// Manual discriminant for `SERIALIZED_VERSION_HASH`: bump to force a cache / /// baked-blob invalidation for a layout change the structural fingerprint below /// cannot observe (e.g. a semantic change to how a field is interpreted). - const serialized_layout_version: u32 = 13; + const serialized_layout_version: u32 = 15; /// Comptime fingerprint of `Serialized`'s layout, mirroring /// `cache_module.MODULE_ENV_VERSION_HASH`. It is appended to the baked builtin @@ -30108,8 +30111,8 @@ test "SERIALIZED_VERSION_HASH golden value" { // change, bump `serialized_layout_version` and replace the golden bytes below with // the ones this assertion prints. const golden: [32]u8 = .{ - 0x53, 0x6C, 0x81, 0xD8, 0xE9, 0x70, 0x9A, 0x09, 0x06, 0x72, 0x9E, 0xC7, 0x6C, 0xA0, 0xED, 0x25, - 0x1D, 0xD4, 0xC9, 0x03, 0xBC, 0xD1, 0xD3, 0x05, 0xFF, 0xC4, 0xD1, 0x13, 0x72, 0x09, 0xBB, 0xC8, + 0x64, 0x30, 0xC0, 0x41, 0xF2, 0x06, 0x3E, 0x75, 0xA1, 0x36, 0x1C, 0xBE, 0xF3, 0xC3, 0x34, 0x90, + 0xB6, 0xDE, 0x73, 0xD6, 0x41, 0x6E, 0x97, 0x70, 0x83, 0x25, 0xA8, 0x7D, 0xB5, 0x4D, 0xD2, 0xC5, }; try std.testing.expectEqualSlices(u8, &golden, &CheckedModuleArtifact.SERIALIZED_VERSION_HASH); } diff --git a/src/check/const_store.zig b/src/check/const_store.zig index 2c1a8eb0c72..8abaa1038e1 100644 --- a/src/check/const_store.zig +++ b/src/check/const_store.zig @@ -72,6 +72,18 @@ pub const TypeDef = struct { /// Declaring statement: within-module discriminator for same-named /// block-local declarations. source_decl: ?u32 = null, + /// Compiler-generated specialization identity for internal nominals minted + /// after checking while preserving the public declaration identity. + generated: ?names.TypeDigest = null, + iterator_representation: IteratorRepresentation = .none, + iterator_depth: u8 = 0, +}; + +/// Checked iterator representation tier preserved for post-check consumers. +pub const IteratorRepresentation = enum(u8) { + none, + minted, + forced_dynamic, }; /// How much of a stored named type's backing type later stages may inspect. @@ -460,6 +472,9 @@ pub const ConstTypeStore = struct { .module = try translation.target.internModuleIdentity(translation.source.moduleIdentityBytes(def.module)), .type_name = try translation.target.internTypeName(translation.source.typeNameText(def.type_name)), .source_decl = def.source_decl, + .generated = def.generated, + .iterator_representation = def.iterator_representation, + .iterator_depth = def.iterator_depth, }; } diff --git a/src/check/static_dispatch_registry.zig b/src/check/static_dispatch_registry.zig index ed6ca5c2738..715a30072d3 100644 --- a/src/check/static_dispatch_registry.zig +++ b/src/check/static_dispatch_registry.zig @@ -100,8 +100,20 @@ pub const BuiltinOwner = enum(u8) { crypto_sha256_hasher, crypto_blake3_digest, crypto_blake3_hasher, + iter, + stream, }; +/// The builtin `Iter`/`Stream` nominals hold their step closure by value inside +/// a finite backing record. Later stages consult this to keep that closure a +/// lambda set (inline captures) instead of erasing it to a boxed callable. +pub fn isIteratorOwner(owner: BuiltinOwner) bool { + return switch (owner) { + .iter, .stream => true, + else => false, + }; +} + /// Public `MethodKey` declaration. pub const MethodKey = struct { owner: MethodOwner, diff --git a/src/cli/builder.zig b/src/cli/builder.zig index 4c8aed1a792..607c30ae13c 100644 --- a/src/cli/builder.zig +++ b/src/cli/builder.zig @@ -54,6 +54,7 @@ pub const CompileConfig = struct { host_call_extern: bool = false, // Builtins reach the host via extern symbols (the symbol ABI) pic: bool = false, // Position-independent code (required for shared library output) no_target_libcalls: bool = false, + lower_memory_intrinsics_to_loops: bool = false, /// Check if compiling for the current machine pub fn isNative(self: CompileConfig) bool { @@ -126,6 +127,7 @@ const ZigLLVMEmitOptions = extern struct { bitcode_filename: ?[*:0]const u8, coverage: ZigLLVMCoverageOptions, no_target_libcalls: bool, + lower_memory_intrinsics_to_loops: bool, }; // LLVM Code Generation Optimization Levels @@ -236,6 +238,7 @@ const core_builtin_roots = std.StaticStringMap(void).initComptime(.{ .{ "roc_builtins_num_mul_with_overflow_i128", {} }, .{ "roc_builtins_num_mul_with_overflow_u128", {} }, .{ "roc_builtins_allocate_with_refcount", {} }, + .{ "roc_builtins_box_prepare_update", {} }, .{ "roc_builtins_box_decref_with", {} }, .{ "roc_builtins_box_decref_with_single_thread", {} }, .{ "roc_builtins_box_free_with", {} }, @@ -699,6 +702,7 @@ pub fn compileBitcodeToObject(gpa: Allocator, std_io: std.Io, config: CompileCon .bitcode_filename = null, .coverage = coverage_options, .no_target_libcalls = config.no_target_libcalls, + .lower_memory_intrinsics_to_loops = config.lower_memory_intrinsics_to_loops, }; const emit_result = externs.ZigLLVMTargetMachineEmitToFile( diff --git a/src/cli/linker.zig b/src/cli/linker.zig index d4499d7011e..40094657b44 100644 --- a/src/cli/linker.zig +++ b/src/cli/linker.zig @@ -12,6 +12,7 @@ const embedded_lld = @import("embedded_lld"); const stack_probe = embedded_lld.stack_probe; const CodeSignature = @import("vendor_macho").CodeSignature; const DwarfSplice = @import("macho/DwarfSplice.zig"); +const backend = @import("backend"); const roc_target = @import("roc_target"); const RocTarget = roc_target.RocTarget; const cli_ctx = @import("CliCtx.zig"); @@ -26,6 +27,33 @@ const suppress_linker_warnings = builtin.mode != .Debug; /// The embedded LLD entrypoints are only linked into LLVM-enabled CLI builds. const llvm_available = if (@import("builtin").is_test) false else @import("config").llvm; +const binaryen_available = if (@import("builtin").is_test) false else @import("config").binaryen; + +const RocBinaryenOptimizeConfig = extern struct { + optimize_level: c_int, + shrink_level: c_int, + zero_filled_memory: u8, + debug_info: u8, + strip_debug: u8, + strip_producers: u8, + strip_target_features: u8, + validate: u8, +}; + +const RocBinaryenBuffer = extern struct { + ptr: ?[*]u8, + len: usize, +}; + +const binaryen_externs = if (binaryen_available) struct { + extern fn RocBinaryenOptimizeWasm( + input: [*]const u8, + input_len: usize, + config: RocBinaryenOptimizeConfig, + output: *RocBinaryenBuffer, + ) c_int; + extern fn RocBinaryenFree(ptr: ?*anyopaque) void; +} else struct {}; /// Supported target formats for linking pub const TargetFormat = embedded_lld.Format; @@ -49,6 +77,13 @@ pub const OutputKind = enum { shared_lib, }; +/// Binaryen optimization mode requested for WebAssembly output. +pub const WasmOptimizeMode = enum { + none, + size, + speed, +}; + /// Default WASM initial memory: 64MB pub const DEFAULT_WASM_INITIAL_MEMORY: usize = 64 * 1024 * 1024; @@ -125,10 +160,20 @@ pub const LinkConfig = struct { /// Whether the final WASM module imports `env.memory` instead of defining memory. wasm_import_memory: bool = false, + /// Whether the final WASM memory is guaranteed to start zero-filled. + wasm_zero_filled_memory: bool = false, + + /// Whether to preserve debug information when optimizing linked wasm output. + wasm_debug_info: bool = false, + + /// Whether to run Binaryen over linked wasm output. + wasm_optimize: WasmOptimizeMode = .none, + /// Optional data/global base for freestanding WASM links. wasm_global_base: ?u32 = null, - /// Function exports derived from explicit platform host object exports. + /// Function exports from the platform host object that are part of the + /// final wasm module's host-visible ABI. wasm_exports: []const []const u8 = &.{}, /// Platform files directory (absolute path). Used to find platform-bundled sysroots. @@ -228,6 +273,7 @@ pub const LinkError = error{ OutOfMemory, InvalidArguments, LLVMNotAvailable, + BinaryenNotAvailable, WindowsSDKNotFound, DarwinSysrootNotFound, } || std.zig.system.DetectError; @@ -834,6 +880,13 @@ pub fn link(ctx: *CliCtx, config: LinkConfig) LinkError!void { error.LinkFailed => return LinkError.LinkFailed, }; + if (config.target_format == .wasm and config.wasm_optimize != .none and !config.disable_output) { + optimizeWasmOutput(ctx, config) catch |err| { + std.log.warn("Failed to optimize wasm output {s}: {}", .{ config.output_path, err }); + return LinkError.LinkFailed; + }; + } + // On macOS, ld64.lld does not write LC_MAIN.stacksize from a `-stack_size` // arg (zig's own MachO linker does, but we link via the LLVM ld64.lld C // API). Patch it ourselves so the main thread gets 64 MiB instead of the @@ -859,6 +912,68 @@ pub fn link(ctx: *CliCtx, config: LinkConfig) LinkError!void { } } +fn binaryenStatusName(status: c_int) []const u8 { + return switch (status) { + 1 => "invalid arguments", + 2 => "read failed", + 3 => "validation failed", + 4 => "write failed", + else => "unknown error", + }; +} + +fn binaryenConfig(config: LinkConfig) RocBinaryenOptimizeConfig { + const Levels = struct { + optimize: c_int, + shrink: c_int, + }; + const levels: Levels = switch (config.wasm_optimize) { + .none => unreachable, + .size => .{ .optimize = 2, .shrink = 2 }, + .speed => .{ .optimize = 3, .shrink = 0 }, + }; + return .{ + .optimize_level = levels.optimize, + .shrink_level = levels.shrink, + .zero_filled_memory = @intFromBool(config.wasm_zero_filled_memory), + .debug_info = @intFromBool(config.wasm_debug_info), + .strip_debug = @intFromBool(!config.wasm_debug_info), + .strip_producers = 1, + .strip_target_features = @intFromBool(config.wasm_optimize == .size and !config.wasm_debug_info), + .validate = 1, + }; +} + +fn optimizeWasmOutput(ctx: *CliCtx, config: LinkConfig) LinkError!void { + if (comptime !binaryen_available) { + return LinkError.BinaryenNotAvailable; + } + + const bytes = std.Io.Dir.cwd().readFileAlloc(ctx.io.std_io, config.output_path, ctx.gpa, .limited(std.math.maxInt(u32))) catch |err| switch (err) { + error.OutOfMemory => return LinkError.OutOfMemory, + else => return LinkError.LinkFailed, + }; + defer ctx.gpa.free(bytes); + + var output = RocBinaryenBuffer{ .ptr = null, .len = 0 }; + const status = binaryen_externs.RocBinaryenOptimizeWasm( + bytes.ptr, + bytes.len, + binaryenConfig(config), + &output, + ); + defer if (output.ptr) |ptr| binaryen_externs.RocBinaryenFree(@ptrCast(ptr)); + + if (status != 0) { + std.log.err("Binaryen failed for {s}: {s}", .{ config.output_path, binaryenStatusName(status) }); + return LinkError.LinkFailed; + } + + const output_ptr = output.ptr orelse return LinkError.LinkFailed; + const output_bytes = output_ptr[0..output.len]; + backend.writeFileWindowsAvSafe(ctx.io.std_io, config.output_path, output_bytes) catch return LinkError.LinkFailed; +} + const macho = std.macho; const deterministic_macho_code_signature_identifier = "roc"; @@ -1041,6 +1156,17 @@ test "link config creation" { try std.testing.expectEqual(@as(usize, 0), config.platform_files_post.len); } +test "size wasm strips final target feature metadata" { + const size = binaryenConfig(.{ .output_path = "out.wasm", .object_files = &.{}, .wasm_optimize = .size }); + try std.testing.expectEqual(@as(u8, 1), size.strip_target_features); + + const size_debug = binaryenConfig(.{ .output_path = "out.wasm", .object_files = &.{}, .wasm_optimize = .size, .wasm_debug_info = true }); + try std.testing.expectEqual(@as(u8, 0), size_debug.strip_target_features); + + const speed = binaryenConfig(.{ .output_path = "out.wasm", .object_files = &.{}, .wasm_optimize = .speed }); + try std.testing.expectEqual(@as(u8, 0), speed.strip_target_features); +} + test "target format detection" { const detected = TargetFormat.detectFromSystem(); diff --git a/src/cli/main.zig b/src/cli/main.zig index dd635f1b5de..e79ce4efea8 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -7193,17 +7193,42 @@ fn configuredWasmMinimumMemory(args: cli_args.BuildArgs, wasm: ?roc_target.WasmT return linker.DEFAULT_WASM_INITIAL_MEMORY; } +/// Whether linked wasm output may assume linear memory starts zero-filled. +/// Fresh (non-imported) wasm memory is always zeroed; imported memory is +/// zeroed only when the platform's targets config declares +/// `import_memory: Zeroed`. Mirrors the dev backend's +/// `omit_zero_fill_data_segments` decision in `configuredWasmMemory`. +fn configuredWasmZeroFilledMemory(wasm: ?roc_target.WasmTargetConfig) bool { + if (wasm) |config| { + return !config.import_memory.importsMemory() or config.import_memory.importedMemoryIsZeroed(); + } + return true; +} + +/// Binaryen post-link optimization mode for linked wasm output, derived from +/// the build's opt level: LLVM opt levels get the matching Binaryen pass; +/// dev/interpreter builds skip Binaryen entirely. +fn wasmOptimizeMode(opt: cli_args.OptLevel) linker.WasmOptimizeMode { + return switch (opt) { + .size => .size, + .speed => .speed, + .dev, .interpreter => .none, + }; +} + fn configuredWasmMemory( args: cli_args.BuildArgs, wasm: ?roc_target.WasmTargetConfig, ) backend.wasm.WasmModule.FinalMemoryConfig { const stack_bytes = configuredWasmStackBytes(args, wasm); + const import_memory = if (wasm) |config| config.import_memory.importsMemory() else false; return .{ .stack_bytes = @intCast(stack_bytes), - .import_memory = if (wasm) |config| config.import_memory else false, + .import_memory = import_memory, + .imported_memory_zeroed = if (wasm) |config| config.import_memory.importedMemoryIsZeroed() else false, .minimum_memory = configuredWasmMinimumMemory(args, wasm), .maximum_memory = if (wasm) |config| config.maximum_memory else null, - .export_memory = if (wasm) |config| !config.import_memory else true, + .export_memory = !import_memory, }; } @@ -7352,6 +7377,10 @@ fn collectWasmPlatformExports( link_inputs: PlatformLinkInputs, owned_inputs: *std.ArrayList([]u8), ) CliMainError![]const []const u8 { + if (link_inputs.wasm) |wasm| { + if (wasm.exports) |exports| return exports; + } + var exports = std.array_list.Managed([]const u8).init(ctx.arena); for (link_inputs.platform_files_pre) |path| { @@ -7369,6 +7398,7 @@ fn writeDevWasmObject( build_cache_dir: []const u8, lowered: *const lir.CheckedPipeline.LoweredProgram, entrypoints: []const backend.Entrypoint, + static_data_exports: []const backend.StaticDataExport, ) CliMainError![]const u8 { if (entrypoints.len == 0) { if (builtin.mode == .Debug) { @@ -7438,6 +7468,7 @@ fn writeDevWasmObject( for (entrypoints) |entry| { _ = try codegen.module.findDefinedFunctionSymbolExact(entry.symbol_name); } + try mergeStaticDataWasmModule(ctx, &codegen.module, static_data_exports, .relocatable_object); try codegen.module.verifyNoLinkObjectContract(); const wasm_bytes = try codegen.module.encodeRelocatable(ctx.gpa); @@ -7463,6 +7494,7 @@ fn rocBuildWasmSurgical( targets_config: roc_target.TargetsConfig, lowered: *const lir.CheckedPipeline.LoweredProgram, entrypoints: []const backend.Entrypoint, + static_data_exports: []const backend.StaticDataExport, ) CliMainError!void { if (entrypoints.len == 0) { if (builtin.mode == .Debug) { @@ -7476,7 +7508,7 @@ fn rocBuildWasmSurgical( if (link_type == .archive) { // Archives package whatever inputs the platform declared (possibly // just the app); no platform wasm file is required. - const obj_path = try writeDevWasmObject(ctx, build_cache_dir, lowered, entrypoints); + const obj_path = try writeDevWasmObject(ctx, build_cache_dir, lowered, entrypoints, static_data_exports); try writeArchiveOutput(ctx, .wasm32, final_output_path, link_inputs, &.{obj_path}); return; } @@ -7490,7 +7522,7 @@ fn rocBuildWasmSurgical( defer freeOwnedWasmInputs(ctx, &owned_inputs); if (link_inputs.wasm != null) { - const obj_path = try writeDevWasmObject(ctx, build_cache_dir, lowered, entrypoints); + const obj_path = try writeDevWasmObject(ctx, build_cache_dir, lowered, entrypoints, static_data_exports); const object_files = try ctx.arena.alloc([]const u8, 1); object_files[0] = obj_path; const wasm_exports = try collectWasmPlatformExports(ctx, link_inputs, &owned_inputs); @@ -7510,7 +7542,10 @@ fn rocBuildWasmSurgical( .wasm_initial_memory = configuredWasmMinimumMemory(args, link_inputs.wasm), .wasm_maximum_memory = if (link_inputs.wasm) |wasm| wasm.maximum_memory else null, .wasm_stack_size = configuredWasmStackBytes(args, link_inputs.wasm), - .wasm_import_memory = if (link_inputs.wasm) |wasm| wasm.import_memory else false, + .wasm_import_memory = if (link_inputs.wasm) |wasm| wasm.import_memory.importsMemory() else false, + .wasm_zero_filled_memory = configuredWasmZeroFilledMemory(link_inputs.wasm), + .wasm_debug_info = args.debug, + .wasm_optimize = wasmOptimizeMode(args.opt), .wasm_global_base = if (link_inputs.wasm) |wasm| wasm.global_base else null, .wasm_exports = wasm_exports, .platform_files_dir = link_inputs.platform_files_dir, @@ -7592,6 +7627,7 @@ fn rocBuildWasmSurgical( } try codegen.flushPendingBodies(); + try mergeStaticDataWasmModule(ctx, &codegen.module, static_data_exports, .final_link); try codegen.module.linkHostToAppCalls(host_to_app_map.items); const memory_config = configuredWasmMemory(args, link_inputs.wasm); @@ -7879,7 +7915,7 @@ fn validateWasmStaticFunctionRelocations( } } -fn mergeLlvmStaticDataWasmModule( +fn mergeStaticDataWasmModule( ctx: *CliCtx, module: *backend.wasm.WasmModule, static_data_exports: []const backend.StaticDataExport, @@ -7919,7 +7955,7 @@ fn writeCombinedLlvmWasmObject( var app_merge = try wasm_module.mergeModuleForObject(&app_module); app_merge.deinit(); - try mergeLlvmStaticDataWasmModule(ctx, &wasm_module, static_data_exports, .relocatable_object); + try mergeStaticDataWasmModule(ctx, &wasm_module, static_data_exports, .relocatable_object); try wasm_module.verifyNoLinkObjectContract(); const wasm_bytes = try wasm_module.encodeRelocatable(ctx.gpa); @@ -7994,7 +8030,10 @@ fn rocBuildWasmLlvm( .wasm_initial_memory = configuredWasmMinimumMemory(args, link_inputs.wasm), .wasm_maximum_memory = if (link_inputs.wasm) |wasm| wasm.maximum_memory else null, .wasm_stack_size = configuredWasmStackBytes(args, link_inputs.wasm), - .wasm_import_memory = if (link_inputs.wasm) |wasm| wasm.import_memory else false, + .wasm_import_memory = if (link_inputs.wasm) |wasm| wasm.import_memory.importsMemory() else false, + .wasm_zero_filled_memory = configuredWasmZeroFilledMemory(link_inputs.wasm), + .wasm_debug_info = args.debug, + .wasm_optimize = wasmOptimizeMode(args.opt), .wasm_global_base = if (link_inputs.wasm) |wasm| wasm.global_base else null, .wasm_exports = wasm_exports, .platform_files_dir = link_inputs.platform_files_dir, @@ -8031,7 +8070,7 @@ fn rocBuildWasmLlvm( var app_merge = try wasm_module.mergeModule(&app_module); app_merge.deinit(); - try mergeLlvmStaticDataWasmModule(ctx, &wasm_module, static_data_exports, .final_link); + try mergeStaticDataWasmModule(ctx, &wasm_module, static_data_exports, .final_link); var host_to_app_map: std.ArrayList(backend.wasm.WasmModule.HostToAppEntry) = .empty; defer host_to_app_map.deinit(ctx.gpa); @@ -8221,7 +8260,7 @@ fn rocBuildLlvm(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!void { }, }; - const build_roots = try lir.CheckedPipeline.selectPlatformExportRoots(ctx.gpa, root_artifact.root_requests.runtime_requests); + const build_roots = try lir.CheckedPipeline.selectPlatformEntrypointRoots(ctx.gpa, root_artifact.root_requests.runtime_requests); defer ctx.gpa.free(build_roots); reporter.begin("Specializing"); @@ -8558,7 +8597,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!void { }, }; - const build_roots = try lir.CheckedPipeline.selectPlatformExportRoots(ctx.gpa, root_artifact.root_requests.runtime_requests); + const build_roots = try lir.CheckedPipeline.selectPlatformEntrypointRoots(ctx.gpa, root_artifact.root_requests.runtime_requests); defer ctx.gpa.free(build_roots); reporter.begin("Specializing"); @@ -8584,6 +8623,17 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!void { const entrypoints = try nativeBuildEntrypoints(ctx, root_artifact, &lowered); defer ctx.gpa.free(entrypoints); + const static_data_exports = try compile.static_data_exports.buildProvidedDataExports( + ctx.gpa, + .{ + .root = check.CheckedArtifact.loweringViewWithRelations(root_artifact, relation_artifacts), + .imports = imported_artifacts, + }, + &lowered, + target, + ); + defer compile.static_data_exports.deinitProvidedDataExports(ctx.gpa, static_data_exports); + if (target_arch == .wasm32) { reporter.begin("Code Generation"); try rocBuildWasmSurgical( @@ -8597,6 +8647,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!void { resolved_targets_config, &lowered, entrypoints, + static_data_exports, ); reporter.end(); @@ -8615,17 +8666,6 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!void { } reporter.begin("Code Generation"); - const static_data_exports = try compile.static_data_exports.buildProvidedDataExports( - ctx.gpa, - .{ - .root = check.CheckedArtifact.loweringViewWithRelations(root_artifact, relation_artifacts), - .imports = imported_artifacts, - }, - &lowered, - target, - ); - defer compile.static_data_exports.deinitProvidedDataExports(ctx.gpa, static_data_exports); - if (entrypoints.len == 0 and static_data_exports.len == 0) { if (builtin.mode == .Debug) { std.debug.panic("native build invariant violated: no exported platform entrypoints or data symbols", .{}); @@ -8990,7 +9030,10 @@ fn rocBuildEmbedded(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!void { .wasm_initial_memory = configuredWasmMinimumMemory(args, link_inputs.wasm), .wasm_maximum_memory = if (link_inputs.wasm) |wasm| wasm.maximum_memory else null, .wasm_stack_size = configuredWasmStackBytes(args, link_inputs.wasm), - .wasm_import_memory = if (link_inputs.wasm) |wasm| wasm.import_memory else false, + .wasm_import_memory = if (link_inputs.wasm) |wasm| wasm.import_memory.importsMemory() else false, + .wasm_zero_filled_memory = configuredWasmZeroFilledMemory(link_inputs.wasm), + .wasm_debug_info = args.debug, + .wasm_optimize = wasmOptimizeMode(args.opt), .wasm_global_base = if (link_inputs.wasm) |wasm| wasm.global_base else null, .platform_files_dir = link_inputs.platform_files_dir, .scratch_dir = build_cache_dir, diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index a3985952ee0..096de365ad7 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -26,6 +26,7 @@ const harness = @import("test_harness"); const platform_config = @import("platform_config.zig"); const util = @import("util.zig"); const collections = @import("collections"); +const roc_backend = @import("backend"); const child_command_timeout_reserve_ms: u64 = 1_000; const timeout_result_grace_ms: u64 = 5_000; @@ -347,6 +348,7 @@ const CustomCase = enum { default_platform_build_arm64glibc, default_platform_build_wasm32, default_platform_wasm32_archive_reproducible, + rocci_dev_wasm_static_data_build, macos_output_basename_reproducible, default_platform_crash_x64musl, default_platform_crash_arm64musl, @@ -823,6 +825,7 @@ const subcommand_cases = [_]CliCase{ .{ .id = 0, .suite = .subcommands, .name = "issue 9519: one lifted function id per monotype specialization across a file split", .body = .{ .command = .{ .args = &.{ "test", "--no-cache" }, .roc_file = "test/cli/issue_9519_split/Main.roc", .exit = .success, .contains = &.{.{ .stream = .stdout, .text = "passed" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "assigned two lifted function ids" }, .{ .stream = .stderr, .text = "postcheck invariant violated" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "issue 9717: spec-constr record cloning reaches target validation on LLVM speed backend", .backend = .speed, .body = .{ .command = .{ .args = &.{ "build", "--opt=speed", "--no-cache" }, .roc_file = "test/cli/Issue9717SpecConstrSpanInvalidation.roc", .exit = .failure, .contains = &.{.{ .stream = .stderr, .text = "MISSING TARGET FILE" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "Segmentation fault" }, .{ .stream = .stderr, .text = "SIGSEGV" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "issue 9801: spec-constr call-pattern collection survives program.fns reallocation on LLVM size backend", .backend = .size, .body = .{ .command = .{ .args = &.{ "build", "--target=wasm32", "--opt=size", "--no-cache" }, .roc_file = "test/wasm/issue_9801_spec_constr_realloc/app.roc", .exit = .not_panic, .not_contains = &.{ .{ .stream = .stderr, .text = "index out of bounds" }, .{ .stream = .stderr, .text = "Segmentation fault" }, .{ .stream = .stderr, .text = "SIGSEGV" }, .{ .stream = .stderr, .text = "panic" } } } } }, + .{ .id = 0, .suite = .subcommands, .name = "dev wasm build merges static data exports", .backend = .dev, .body = .{ .custom = .rocci_dev_wasm_static_data_build } }, .{ .id = 0, .suite = .subcommands, .name = "direct LIR callable calls survive variant table growth on LLVM speed backend", .backend = .speed, .body = .{ .command = .{ .args = &.{ "build", "--opt=speed", "--no-cache" }, .roc_file = "test/cli/direct_lir_callable_variant_span_invalidation.roc", .exit = .success, .contains = &.{.{ .stream = .stdout, .text = "successfully building" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "direct LIR reachability referenced a missing function spec" }, .{ .stream = .stderr, .text = "postcheck invariant violated" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "issue 9815: roc check reports discarded Iter.collect polymorphic output without panic", .body = .{ .command = .{ .args = &.{ "check", "--no-cache" }, .roc_file = "test/cli/issue_9815_iter_collect_polymorphic_where.roc", .exit = .failure, .stderr_min_len = 1, .contains = &.{ .{ .stream = .stderr, .text = "MISSING METHOD" }, .{ .stream = .stderr, .text = "from_iter" } }, .not_contains = &.{ .{ .stream = .stderr, .text = "checked artifact invariant violated" }, .{ .stream = .stderr, .text = "unresolved `where`-clause method dispatch on a polymorphic value" }, .{ .stream = .stderr, .text = "dispatch plan had no method owner" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "issue 9815: roc run turns discarded user where-clause error into ordinary crash", .body = .{ .command = .{ .args = &.{"--no-cache"}, .roc_file = "test/cli/issue_9815_discarded_user_where_clause_output.roc", .exit = .failure, .contains = &.{ .{ .stream = .stderr, .text = "MISSING METHOD" }, .{ .stream = .stderr, .text = "from_thing" }, .{ .stream = .stderr, .text = "Roc application crashed with this message:" } }, .not_contains = &.{ .{ .stream = .stderr, .text = "unresolved `where`-clause method dispatch on a polymorphic value" }, .{ .stream = .stderr, .text = "dispatch plan had no method owner" }, .{ .stream = .stderr, .text = "panic" } } } } }, @@ -1949,6 +1952,7 @@ fn runCustomCase( .default_platform_build_arm64glibc => customDefaultPlatformBuild(io, allocator, &env, &timer, timeout_ms, .arm64glibc), .default_platform_build_wasm32 => customDefaultPlatformBuild(io, allocator, &env, &timer, timeout_ms, .wasm32), .default_platform_wasm32_archive_reproducible => customDefaultPlatformWasm32ArchiveReproducible(io, allocator, &env, &timer, timeout_ms), + .rocci_dev_wasm_static_data_build => customRocciDevWasmStaticDataBuild(io, allocator, &env, &timer, timeout_ms), .macos_output_basename_reproducible => customMacosOutputBasenameReproducible(io, allocator, &env, &timer, timeout_ms), .default_platform_crash_x64musl => customDefaultPlatformDebugBacktrace(io, allocator, &env, &timer, timeout_ms, .x64musl, .crash), .default_platform_crash_arm64musl => customDefaultPlatformDebugBacktrace(io, allocator, &env, &timer, timeout_ms, .arm64musl, .crash), @@ -4154,6 +4158,67 @@ fn customDefaultPlatformWasm32ArchiveReproducible( return null; } +fn customRocciDevWasmStaticDataBuild( + io: std.Io, + allocator: Allocator, + env: *const CaseEnv, + timer: *harness.Timer, + timeout_ms: u64, +) ?TestResult { + const output_path = std.fs.path.join(allocator, &.{ env.dirs.work_dir, "rocci_bird_dev.wasm" }) catch |err| + return customInfraFailure(allocator, timer, "failed to allocate Rocci dev wasm output path: {}", .{err}); + const out_arg = outputArg(allocator, output_path) catch |err| + return customInfraFailure(allocator, timer, "failed to allocate Rocci dev wasm output arg: {}", .{err}); + + if (runRocAndCheck(io, allocator, env, timer, timeout_ms, .{ + .args = &.{ "build", "--opt=dev", "--target=wasm32", "--no-cache", out_arg }, + .roc_file = "test/cli/rocci_bird_postcheck_panic/main.roc", + .contains = &.{.{ .stream = .stdout, .text = "successfully building" }}, + .not_contains = &.{ + .{ .stream = .stderr, .text = "UnexpectedUndefinedSymbol" }, + .{ .stream = .stderr, .text = "panic" }, + }, + })) |failure| return failure; + + var file = std.Io.Dir.cwd().openFile(io, output_path, .{ .mode = .read_only }) catch |err| + return customInfraFailure(allocator, timer, "failed to open Rocci dev wasm output: {}", .{err}); + defer file.close(io); + + var magic: [4]u8 = undefined; + const bytes_read = file.readPositionalAll(io, &magic, 0) catch |err| + return customInfraFailure(allocator, timer, "failed to read Rocci dev wasm magic: {}", .{err}); + if (bytes_read != magic.len or !std.mem.eql(u8, magic[0..], &.{ 0, 'a', 's', 'm' })) { + return customFailure(allocator, timer, "Rocci dev wasm output had invalid wasm magic", .{}); + } + + const wasm_bytes = std.Io.Dir.cwd().readFileAlloc(io, output_path, allocator, .limited(64 * 1024 * 1024)) catch |err| + return customInfraFailure(allocator, timer, "failed to read Rocci dev wasm output: {}", .{err}); + defer allocator.free(wasm_bytes); + var module = roc_backend.wasm.WasmModule.preload(allocator, wasm_bytes, false) catch |err| + return customInfraFailure(allocator, timer, "failed to parse Rocci dev wasm output: {}", .{err}); + defer module.deinit(); + + var has_start = false; + var has_update = false; + var has_other = false; + for (module.exports.items) |exported| { + if (exported.kind != .func) { + has_other = true; + } else if (std.mem.eql(u8, exported.name, "start")) { + has_start = true; + } else if (std.mem.eql(u8, exported.name, "update")) { + has_update = true; + } else { + has_other = true; + } + } + if (module.exports.items.len != 2 or !has_start or !has_update or has_other) { + return customFailure(allocator, timer, "Rocci dev wasm did not contain exactly the configured update/start function exports", .{}); + } + + return null; +} + fn customMacosOutputBasenameReproducible( io: std.Io, allocator: Allocator, diff --git a/src/collections/guarded_list_violation_test.zig b/src/collections/guarded_list_violation_test.zig index 56d18831191..2f96b6efa1a 100644 --- a/src/collections/guarded_list_violation_test.zig +++ b/src/collections/guarded_list_violation_test.zig @@ -346,6 +346,7 @@ fn emptyLiftedProgram(allocator: Allocator) Lifted.Ast.Program { .empty, .empty, .empty, + .empty, 0, ); } diff --git a/src/compile/cache_config.zig b/src/compile/cache_config.zig index 12471f0b698..be1fb7c134c 100644 --- a/src/compile/cache_config.zig +++ b/src/compile/cache_config.zig @@ -36,10 +36,11 @@ pub const Constants = struct { /// 11: Builtin indices and common identifiers changed for Encoding.Json and Encoding.HttpHeader. /// 12: Builtin.Encoding.Json structural encode/parse support changed common identifiers. /// 13: ModuleEnv stores deep content identity hashes. - /// 14: ModuleEnv also stores scheme instantiation evidence records. - /// 15: Checked encoder_for runtime representation changed serialized compiler state. - /// 16: Static dispatch constraints carry introducing-site provenance. - pub const CACHE_VERSION = 16; + /// 14: merged cache layout changes from static builtins and module identities. + /// 15: ModuleEnv also stores scheme instantiation evidence records. + /// 16: Checked encoder_for runtime representation changed serialized compiler state. + /// 17: Static dispatch constraints carry introducing-site provenance. + pub const CACHE_VERSION = 17; }; /// Configuration for the Roc cache system. diff --git a/src/compile/cache_module.zig b/src/compile/cache_module.zig index 39ef2736bd9..f6443b87987 100644 --- a/src/compile/cache_module.zig +++ b/src/compile/cache_module.zig @@ -290,8 +290,8 @@ test "MODULE_ENV_VERSION_HASH golden value" { // an *intentional* layout change, bump `Constants.CACHE_VERSION` and replace the // golden bytes below with the ones this assertion prints. const golden: [32]u8 = .{ - 0xED, 0xD5, 0x5F, 0xD5, 0x57, 0x39, 0xC4, 0xC5, 0xD1, 0xB2, 0x29, 0x6E, 0x27, 0x01, 0xD0, 0x10, - 0x12, 0xB8, 0xC4, 0x54, 0xB6, 0xEF, 0xEB, 0xBE, 0xC2, 0x8E, 0xE1, 0x0A, 0x9E, 0xB3, 0x21, 0x0F, + 0x50, 0x7C, 0x3A, 0x9E, 0x7C, 0x7F, 0x6D, 0x3A, 0x92, 0x74, 0xF4, 0x69, 0x1B, 0xD2, 0x5B, 0x23, + 0xDE, 0x49, 0x74, 0xAF, 0xE3, 0xEF, 0x93, 0xE2, 0x3B, 0xDC, 0xB8, 0x10, 0xBA, 0xEE, 0x41, 0x54, }; try std.testing.expectEqualSlices(u8, &golden, &MODULE_ENV_VERSION_HASH); } diff --git a/src/compile/compile_package.zig b/src/compile/compile_package.zig index f231b7e29a7..80cc089b073 100644 --- a/src/compile/compile_package.zig +++ b/src/compile/compile_package.zig @@ -2174,6 +2174,7 @@ pub const PackageEnv = struct { var seen = std.AutoHashMap(CheckedArtifact.CheckedModuleArtifactKey, void).init(self.gpa); defer seen.deinit(); + try pending.append(self.gpa, CheckedArtifact.importedView(&self.builtin_modules.checked_artifact)); for (imported_artifacts) |imported| { try pending.append(self.gpa, imported.view); } diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index e6325221f73..71598f28521 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -5528,7 +5528,7 @@ fn overwriteFilesUnderDir(allocator: Allocator, absolute_dir: []const u8, conten return overwritten; } -fn corruptFirstCheckedModuleEnvIdentBytesLen(allocator: Allocator, checked_module_cache_dir: []const u8) CorruptCheckedModuleCacheError!void { +fn corruptCheckedModuleEnvIdentBytesLen(allocator: Allocator, checked_module_cache_dir: []const u8) CorruptCheckedModuleCacheError!usize { const env_ident_bytes_len_offset = checked_module_cache_header_len + @offsetOf(ModuleEnv.Serialized, "common") + @@ -5544,6 +5544,7 @@ fn corruptFirstCheckedModuleEnvIdentBytesLen(allocator: Allocator, checked_modul var walker = try dir.walk(allocator); defer walker.deinit(); + var corrupted: usize = 0; while (try walker.next(io)) |entry| { if (entry.kind != .file) continue; @@ -5553,10 +5554,10 @@ fn corruptFirstCheckedModuleEnvIdentBytesLen(allocator: Allocator, checked_modul std.mem.writeInt(u64, bytes[env_ident_bytes_len_offset..][0..8], std.math.maxInt(u64), .little); try dir.writeFile(io, .{ .sub_path = entry.path, .data = bytes }); - return; + corrupted += 1; } - return error.FileNotFound; + return corrupted; } test "Coordinator checked cache key requires checked direct imports" { @@ -5732,7 +5733,8 @@ test "Coordinator corrupt checked module cache env relocations compile from sour const config = CacheConfig{ .cache_dir = cache_dir }; const checked_module_cache_dir = try config.getCheckedArtifactCacheDir(allocator); defer allocator.free(checked_module_cache_dir); - try corruptFirstCheckedModuleEnvIdentBytesLen(allocator, checked_module_cache_dir); + const corrupted = try corruptCheckedModuleEnvIdentBytesLen(allocator, checked_module_cache_dir); + try std.testing.expect(corrupted > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); try std.testing.expect(second.build.modules_compiled > 0); diff --git a/src/compile/mod.zig b/src/compile/mod.zig index c01e10a356f..e960d401f1c 100644 --- a/src/compile/mod.zig +++ b/src/compile/mod.zig @@ -109,6 +109,7 @@ test "compile tests" { std.testing.refAllDecls(@import("test/type_printing_bug_test.zig")); std.testing.refAllDecls(@import("test/embedding_smoke.zig")); std.testing.refAllDecls(@import("test/hoisted_constants_test.zig")); + std.testing.refAllDecls(@import("test/comptime_diagnostics_test.zig")); std.testing.refAllDecls(@import("test/issue_9614_test.zig")); std.testing.refAllDecls(@import("test/issue_9634_test.zig")); std.testing.refAllDecls(@import("test/issue_806_stack_aggregate_test.zig")); @@ -118,5 +119,6 @@ test "compile tests" { std.testing.refAllDecls(@import("test/issue_9884_test.zig")); std.testing.refAllDecls(@import("test/tce_capture_test.zig")); std.testing.refAllDecls(@import("test/list_map_target_independent_lir_test.zig")); + std.testing.refAllDecls(@import("test/platform_box_update_lir_test.zig")); std.testing.refAllDecls(@import("test/url_package_test.zig")); } diff --git a/src/compile/static_data_exports.zig b/src/compile/static_data_exports.zig index 14ddb04b296..2df2177e501 100644 --- a/src/compile/static_data_exports.zig +++ b/src/compile/static_data_exports.zig @@ -60,7 +60,7 @@ const ConstStrDataSite = struct { data: u32, }; -/// Build readonly data exports for provided constants. +/// Build readonly data symbols for provided constants and internal LIR static values. pub fn buildProvidedDataExports( allocator: Allocator, modules: ModuleViews, @@ -69,10 +69,15 @@ pub fn buildProvidedDataExports( ) MaterializationError![]StaticDataExport { const root = modules.root orelse { if (hasProvidedData(modules)) staticDataInvariant("provided data exports require a root checked module"); + if (lowered) |lowered_program| { + if (lowered_program.lir_result.static_data_values.items.len != 0) { + staticDataInvariant("internal static data values require a root checked module"); + } + } return try allocator.alloc(StaticDataExport, 0); }; const lowered_program = lowered orelse { - if (moduleHasProvidedData(root.module)) staticDataInvariant("provided data exports require LIR layout output"); + if (moduleHasProvidedData(root.module)) staticDataInvariant("static data exports require LIR layout output"); return try allocator.alloc(StaticDataExport, 0); }; @@ -146,6 +151,13 @@ const StaticDataBuilder = struct { fn build(self: *StaticDataBuilder) MaterializationError![]StaticDataExport { errdefer self.deinitNodes(); + try self.buildProvidedExports(); + try self.buildInternalStaticValues(); + + return try self.nodes.toOwnedSlice(self.allocator); + } + + fn buildProvidedExports(self: *StaticDataBuilder) MaterializationError!void { for (self.root.module.provided_exports.exports) |provided| { const data = switch (provided) { .data => |data| data, @@ -166,11 +178,31 @@ const StaticDataBuilder = struct { .bytes = materialized.bytes, .alignment = materialized.alignment, .is_global = true, + .is_exported = true, .relocations = materialized.relocations, }); } + } - return try self.nodes.toOwnedSlice(self.allocator); + fn buildInternalStaticValues(self: *StaticDataBuilder) MaterializationError!void { + for (self.lowered.lir_result.static_data_values.items, 0..) |value, index| { + const static_data_id: lir.LIR.StaticDataId = @enumFromInt(@as(u32, @intCast(index))); + const symbol_name = try lir.Program.staticDataSymbolName(self.allocator, static_data_id); + errdefer self.allocator.free(symbol_name); + + const const_node = self.staticDataConstNode(value); + const materialized = try self.materializeValue(const_node, value.plan, value.layout_idx); + errdefer self.deinitMaterialized(materialized); + + try self.nodes.append(self.allocator, .{ + .symbol_name = symbol_name, + .bytes = materialized.bytes, + .alignment = materialized.alignment, + .is_global = true, + .is_exported = false, + .relocations = materialized.relocations, + }); + } } fn deinitNodes(self: *StaticDataBuilder) void { @@ -200,6 +232,18 @@ const StaticDataBuilder = struct { }; } + fn staticDataConstNode(self: *StaticDataBuilder, value: lir.Program.StaticDataValue) ConstNode { + const module = self.moduleForConst(value.const_locator); + if (value.node) |node| return .{ .module = module, .id = node }; + const template = module.templates.get(value.const_locator); + return switch (template.state) { + .stored_const => |stored| .{ .module = module, .id = stored.node }, + .reserved, + .eval_template, + => staticDataInvariant("internal static data const was not stored before static materialization"), + }; + } + fn moduleForConst(self: *StaticDataBuilder, ref: CheckedModule.ConstRef) ConstModule { if (moduleBytesEqual(self.root.module.key.bytes, ref.artifact.bytes)) return .{ .key = self.root.module.key, @@ -207,6 +251,14 @@ const StaticDataBuilder = struct { .templates = &self.root.module.const_templates, .store = &self.root.module.const_store, }; + for (self.root.relation_modules) |relation| { + if (moduleBytesEqual(relation.key.bytes, ref.artifact.bytes)) return .{ + .key = relation.key, + .names = relation.canonical_names, + .templates = relation.const_templates, + .store = relation.const_store, + }; + } for (self.imports) |imported| { if (moduleBytesEqual(imported.key.bytes, ref.artifact.bytes)) return .{ .key = imported.key, @@ -215,7 +267,7 @@ const StaticDataBuilder = struct { .store = imported.const_store, }; } - staticDataInvariant("provided data export referenced a const outside the lowering module set"); + staticDataInvariant("static data export referenced a const outside the lowering module set"); } fn requestedLayout(self: *StaticDataBuilder, checked_type: CheckedModule.CheckedTypeId) lir.Program.RequestedLayout { @@ -880,6 +932,7 @@ const StaticDataBuilder = struct { .bytes = bytes, .alignment = @max(payload_alignment, self.word_size), .is_global = false, + .is_exported = false, .relocations = relocations, }); diff --git a/src/compile/targets_config.zig b/src/compile/targets_config.zig index d00329c8b39..dd2c148a1b7 100644 --- a/src/compile/targets_config.zig +++ b/src/compile/targets_config.zig @@ -33,9 +33,35 @@ pub const LinkItem = union(enum) { win_gui, }; +/// How a wasm target obtains linear memory. +pub const WasmImportMemory = enum { + no, + uninitialized, + zeroed, + + pub fn importsMemory(self: WasmImportMemory) bool { + return self != .no; + } + + pub fn importedMemoryIsZeroed(self: WasmImportMemory) bool { + return self == .zeroed; + } + + pub fn fromTagName(name: []const u8) ?WasmImportMemory { + if (std.mem.eql(u8, name, "No")) return .no; + if (std.mem.eql(u8, name, "Uninitialized")) return .uninitialized; + if (std.mem.eql(u8, name, "Zeroed")) return .zeroed; + return null; + } +}; + /// Optional wasm-specific settings from a target record in a platform header. pub const WasmTargetConfig = struct { - import_memory: bool = false, + /// Final host-visible function exports. `null` preserves the legacy + /// contract where public symbols are read from the platform object; a + /// present slice, including an empty one, is the complete export set. + exports: ?[]const []const u8 = null, + import_memory: WasmImportMemory = .no, minimum_memory: ?usize = null, maximum_memory: ?usize = null, initial_stack_size: ?usize = null, @@ -47,6 +73,10 @@ pub const WasmTargetConfig = struct { global_base_ident: ?[]const u8 = null, fn deinit(self: WasmTargetConfig, allocator: Allocator) void { + if (self.exports) |exports| { + for (exports) |name| allocator.free(name); + allocator.free(exports); + } if (self.import_memory_ident) |ident| allocator.free(ident); if (self.minimum_memory_ident) |ident| allocator.free(ident); if (self.maximum_memory_ident) |ident| allocator.free(ident); @@ -69,7 +99,7 @@ pub const TargetConfigResolveReason = enum { missing_top_level_value, not_constant, unevaluated_constant, - expected_bool, + expected_import_memory, expected_unsigned_integer, integer_out_of_range, @@ -78,7 +108,7 @@ pub const TargetConfigResolveReason = enum { .missing_top_level_value => "does not name a top-level value in the platform module", .not_constant => "names a function, but target configuration requires a constant", .unevaluated_constant => "does not have a stored compile-time constant value", - .expected_bool => "must resolve to True or False", + .expected_import_memory => "must resolve to Zeroed, Uninitialized, or No", .expected_unsigned_integer => "must resolve to a non-negative whole number", .integer_out_of_range => "resolves to a number outside the supported range", }; @@ -281,6 +311,34 @@ pub const TargetsConfig = struct { link_items.clearRetainingCapacity(); } + fn replaceWasmExports( + allocator: Allocator, + store: *const parse.NodeStore, + ast: anytype, + values: parse.AST.TargetConfigValue.Span, + wasm: *WasmTargetConfig, + ) Allocator.Error!void { + if (wasm.exports) |old_exports| { + for (old_exports) |name| allocator.free(name); + allocator.free(old_exports); + wasm.exports = null; + } + + var exports = std.array_list.Managed([]const u8).init(allocator); + errdefer { + for (exports.items) |name| allocator.free(name); + exports.deinit(); + } + + for (store.targetConfigValueSlice(values)) |value_idx| { + switch (store.getTargetConfigValue(value_idx)) { + .string_literal => |tok| try exports.append(try allocator.dupe(u8, ast.resolve(tok))), + else => {}, + } + } + wasm.exports = try exports.toOwnedSlice(); + } + fn storeIdent( allocator: Allocator, ast: anytype, @@ -320,23 +378,13 @@ pub const TargetsConfig = struct { }; } - fn parseBoolValue( + fn parseWasmImportMemoryValue( store: *const parse.NodeStore, ast: anytype, value_idx: parse.AST.TargetConfigValue.Idx, - ) ?bool { + ) ?WasmImportMemory { return switch (store.getTargetConfigValue(value_idx)) { - .tag_literal => |tok| blk: { - const tag = ast.resolve(tok); - if (std.mem.eql(u8, tag, "True")) break :blk true; - if (std.mem.eql(u8, tag, "False")) break :blk false; - break :blk null; - }, - .string_literal => |tok| blk: { - const value = ast.resolve(tok); - if (std.mem.eql(u8, value, "env.memory")) break :blk true; - break :blk null; - }, + .tag_literal => |tok| WasmImportMemory.fromTagName(ast.resolve(tok)), else => null, }; } @@ -367,8 +415,16 @@ pub const TargetsConfig = struct { }, else => {}, } + } else if (std.mem.eql(u8, name, "exports")) { + switch (value) { + .list => |values| { + try replaceWasmExports(allocator, store, ast, values, &wasm); + has_wasm_config = true; + }, + else => {}, + } } else if (std.mem.eql(u8, name, "import_memory")) { - if (parseBoolValue(store, ast, entry.value)) |import_memory| { + if (parseWasmImportMemoryValue(store, ast, entry.value)) |import_memory| { wasm.import_memory = import_memory; has_wasm_config = true; } else if (targetConfigIdentToken(value)) |ident| { @@ -516,20 +572,20 @@ fn resolveWasmCheckedConstants( wasm: *WasmTargetConfig, diagnostic: *TargetConfigResolveDiagnostic, ) error{TargetConfigInvalid}!void { - try resolveWasmBoolField(allocator, checked_module, target, output, "import_memory", &wasm.import_memory, &wasm.import_memory_ident, diagnostic); + try resolveWasmImportMemoryField(allocator, checked_module, target, output, "import_memory", &wasm.import_memory, &wasm.import_memory_ident, diagnostic); try resolveWasmUsizeField(allocator, checked_module, target, output, "minimum_memory", &wasm.minimum_memory, &wasm.minimum_memory_ident, diagnostic); try resolveWasmUsizeField(allocator, checked_module, target, output, "maximum_memory", &wasm.maximum_memory, &wasm.maximum_memory_ident, diagnostic); try resolveWasmUsizeField(allocator, checked_module, target, output, "initial_stack_size", &wasm.initial_stack_size, &wasm.initial_stack_size_ident, diagnostic); try resolveWasmU32Field(allocator, checked_module, target, output, "global_base", &wasm.global_base, &wasm.global_base_ident, diagnostic); } -fn resolveWasmBoolField( +fn resolveWasmImportMemoryField( allocator: Allocator, checked_module: *const checked.CheckedModuleArtifact, target: RocTarget, output: OutputKind, field_name: []const u8, - out: *bool, + out: *WasmImportMemory, ident_slot: *?[]const u8, diagnostic: *TargetConfigResolveDiagnostic, ) error{TargetConfigInvalid}!void { @@ -539,8 +595,8 @@ fn resolveWasmBoolField( diagnostic.* = .{ .target = target, .output = output, .field_name = field_name, .ident_name = ident, .reason = reason }; return error.TargetConfigInvalid; }; - const value = constBool(checked_module, node) orelse { - diagnostic.* = .{ .target = target, .output = output, .field_name = field_name, .ident_name = ident, .reason = .expected_bool }; + const value = constWasmImportMemory(checked_module, node) orelse { + diagnostic.* = .{ .target = target, .output = output, .field_name = field_name, .ident_name = ident, .reason = .expected_import_memory }; return error.TargetConfigInvalid; }; out.* = value; @@ -632,19 +688,12 @@ fn topLevelConstNode( return null; } -fn constBool(checked_module: *const checked.CheckedModuleArtifact, node: checked.ConstNodeId) ?bool { +fn constWasmImportMemory(checked_module: *const checked.CheckedModuleArtifact, node: checked.ConstNodeId) ?WasmImportMemory { return switch (checked_module.const_store.get(node)) { - .nominal => |nominal| constBool(checked_module, nominal.backing), + .nominal => |nominal| constWasmImportMemory(checked_module, nominal.backing), .tag => |tag| blk: { if (tag.payloads.len != 0) break :blk null; - if (std.mem.eql(u8, tag.tag_name, "True")) break :blk true; - if (std.mem.eql(u8, tag.tag_name, "False")) break :blk false; - break :blk null; - }, - .str => |str| blk: { - const bytes = checked_module.const_store.strBytes(str); - if (std.mem.eql(u8, bytes, "env.memory")) break :blk true; - break :blk null; + break :blk WasmImportMemory.fromTagName(tag.tag_name); }, else => null, }; @@ -822,6 +871,50 @@ test "fromAST accepts explicit hostless targets section" { try testing.expectEqual(@as(usize, 0), config.targets.len); } +test "fromAST captures explicit wasm exports" { + const allocator = testing.allocator; + + const source = + \\platform "" + \\ requires { main : {} } + \\ exposes [] + \\ packages {} + \\ provides { "roc_main": main_for_host } + \\ targets: { + \\ inputs_dir: "targets/", + \\ wasm32: { + \\ inputs: ["host.wasm", app], + \\ output: Shared, + \\ exports: ["start", "update"], + \\ }, + \\ } + \\ + ; + + const source_copy = try allocator.dupe(u8, source); + defer allocator.free(source_copy); + + var env = try base.CommonEnv.init(allocator, source_copy); + defer env.deinit(allocator); + + const ast = try parse.file(allocator, &env); + defer ast.deinit(); + + try testing.expectEqual(@as(usize, 0), ast.parse_diagnostics.items.len); + + const maybe_config = try TargetsConfig.fromAST(allocator, ast); + try testing.expect(maybe_config != null); + + const config = maybe_config.?; + defer config.deinit(allocator); + + try testing.expectEqual(@as(usize, 1), config.targets.len); + const exports = config.targets[0].wasm.?.exports.?; + try testing.expectEqual(@as(usize, 2), exports.len); + try testing.expectEqualStrings("start", exports[0]); + try testing.expectEqualStrings("update", exports[1]); +} + test "fromAST captures punned wasm identifier config" { const allocator = testing.allocator; @@ -844,7 +937,7 @@ test "fromAST captures punned wasm identifier config" { \\ }, \\ } \\ - \\import_memory = True + \\import_memory = Zeroed \\minimum_memory = 65536 \\maximum_memory = 65536 \\initial_stack_size = 14752 diff --git a/src/compile/test/comptime_diagnostics_test.zig b/src/compile/test/comptime_diagnostics_test.zig new file mode 100644 index 00000000000..0931722776c --- /dev/null +++ b/src/compile/test/comptime_diagnostics_test.zig @@ -0,0 +1,78 @@ +//! Tests for compile-time diagnostics that are emitted while publishing checked modules. + +// Ported pending iterator redesign: the comptime_dbg problem kind this test counts is not part of the current problem set. +// test "imported eligible top-level diagnostics run during checking" { +// const allocator = std.testing.allocator; +// const main_source = +// \\import Util +// \\main = Util.safe +// ; +// +// const imported_dbg = +// \\module [safe] +// \\ +// \\hidden_dbg : I64 +// \\hidden_dbg = { +// \\ dbg "imported unused dbg" +// \\ 0.I64 +// \\} +// \\ +// \\safe = 42 +// ; +// +// var dbg_resources = try eval.test_helpers.publishProgramKeepingReportedComptimeProblems( +// allocator, +// .module, +// main_source, +// &.{.{ .name = "Util", .source = imported_dbg }}, +// ); +// defer dbg_resources.deinit(allocator); +// +// try std.testing.expectEqual(@as(usize, 1), dbg_resources.extra_modules.len); +// try std.testing.expectEqual(@as(usize, 0), countProblemTag(dbg_resources.checker.problems.problems.items, .comptime_dbg)); +// try std.testing.expectEqual(@as(usize, 1), countProblemTag(dbg_resources.extra_modules[0].checker.problems.problems.items, .comptime_dbg)); +// try std.testing.expectEqual(@as(usize, 0), countProblemTag(dbg_resources.extra_modules[0].checker.problems.problems.items, .comptime_expect_failed)); +// try std.testing.expectEqual(@as(usize, 0), countProblemTag(dbg_resources.extra_modules[0].checker.problems.problems.items, .comptime_crash)); +// +// const imported_expect = +// \\module [safe] +// \\ +// \\hidden_expect : I64 +// \\hidden_expect = { +// \\ expect False +// \\ 0.I64 +// \\} +// \\ +// \\safe = 42 +// ; +// try std.testing.expectEqual( +// eval.test_helpers.ComptimePublishOutcome.comptime_problems, +// try eval.test_helpers.publishProgramForComptimeProblems( +// allocator, +// .module, +// main_source, +// &.{.{ .name = "Util", .source = imported_expect }}, +// ), +// ); +// +// const imported_crash = +// \\module [safe] +// \\ +// \\hidden_crash : I64 +// \\hidden_crash = { +// \\ crash "imported unused crash" +// \\ 0.I64 +// \\} +// \\ +// \\safe = 42 +// ; +// try std.testing.expectEqual( +// eval.test_helpers.ComptimePublishOutcome.comptime_problems, +// try eval.test_helpers.publishProgramForComptimeProblems( +// allocator, +// .module, +// main_source, +// &.{.{ .name = "Util", .source = imported_crash }}, +// ), +// ); +// } diff --git a/src/compile/test/hoisted_constants_test.zig b/src/compile/test/hoisted_constants_test.zig index b735ec0d3e2..012c71a62b8 100644 --- a/src/compile/test/hoisted_constants_test.zig +++ b/src/compile/test/hoisted_constants_test.zig @@ -12,28 +12,64 @@ const roc_target = @import("roc_target"); const Coordinator = @import("../coordinator.zig").Coordinator; const CoreCtx = @import("ctx").CoreCtx; +const static_data_exports = @import("../static_data_exports.zig"); const HoistedConstantsTestError = std.mem.Allocator.Error || + Coordinator.AppDiscoveryError || + check.CheckedArtifact.CompileTimeFinalizer.Error || + eval.BuiltinModules.InitError || std.Io.Dir.CreateDirPathError || + std.Io.Dir.RealPathFileAllocError || std.Io.Dir.WriteFileError || std.Io.File.Writer.Error || + std.Thread.SpawnError || error{ AfterRootHadNoRequest, BeforeRootHadNoRequest, + BuiltinLowLevelAnnotationMustBeFunction, + CompileTimeProblem, + DownloadFailed, ExportedRuntimeEntrypointNotFound, + ExpectedPlatformString, + ExpectedString, + FileError, + HasUserErrors, HoistedConstWasNotI64, HoistedConstWasNotScalar, HoistedRootDidNotStoreConstNode, HoistedRootKindMismatch, HoistedTemplateWasNotStored, + Internal, + InvalidDependency, + InvalidNullByteInPath, + InvalidUrl, + Issue806MissingStackProbe, + Issue806UnsafeLargeStackCallArgument, + Issue806UnsafeLargeStackCallReturn, + Issue806UnsafeLargeStackClosureCapture, + Issue806UnsafeLargeStackJoinParam, + Issue806UnsafeLargeStackPatternPayload, + Issue806UnsafeLargeStackReturn, + Issue806UnsafeLargeStackSetLocalCopy, + Issue806UnsafeLargeStackStructAssign, + Issue806UnsafeLargeStackTagAssign, + LowLevelOperationsNotFound, + NoCacheDir, + NoPackageSource, OutOfMemory, PatternExtractionMissingCheckedRootPattern, PatternExtractionMissingSourcePattern, PatternExtractionRootValueWasNotSyntheticLookup, PatternExtractionRootWasNotSyntheticMatch, + PathOutsideWorkspace, RootDidNotStoreConstNode, + StaticDataLiteralNotFound, + StaticDataSymbolNotFound, TestExpectedEqual, TestUnexpectedResult, + UnsupportedBuiltinAnnotationOnly, + UnsupportedHeader, + WriteFailed, }; test "hoisted local constants are finalized and restored during runtime lowering" { @@ -387,6 +423,225 @@ test "imported checked bodies restore their module's hoisted constants" { defer lowered.deinit(); } +test "hoisted list constants lower to internal static data" { + const gpa = std.testing.allocator; + + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try writeEchoPlatform(tmp_dir.dir); + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "main.roc", + .data = + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\ + \\import pf.Echo + \\ + \\numbers = [11.I64, 22.I64, 33.I64, 44.I64] + \\ + \\main! = |args| { + \\ var $sum = List.len(args).to_i64_wrap() + \\ for n in numbers { + \\ $sum = $sum + n + \\ } + \\ _ = $sum + \\ Echo.line!("done") + \\ Ok({}) + \\} + , + }); + const app_path = try tmp_dir.dir.realPathFileAlloc(std.testing.io, "main.roc", gpa); + defer gpa.free(app_path); + + var arena_impl = collections.SingleThreadArena.init(gpa); + defer arena_impl.deinit(); + const arena = arena_impl.allocator(); + + var builtin_modules = try eval.BuiltinModules.init(gpa); + defer builtin_modules.deinit(); + + var coord = try Coordinator.init( + gpa, + .single_threaded, + 1, + .x64linux, + &builtin_modules, + build_options.compiler_version, + null, + CoreCtx.default(gpa, arena, std.testing.io), + ); + defer coord.deinit(); + coord.enable_hosted_transform = true; + + try coord.start(); + try coord.discoverAppFromPath(arena, .{ .entry_path = app_path }); + try coord.coordinatorLoop(); + try std.testing.expect(!coord.hasUserErrors()); + + try coord.finalizeExecutableArtifacts(); + try std.testing.expect(!coord.hasUserErrors()); + + const root = coord.executableRootCheckedArtifact(); + const imports = try coord.collectImportedArtifactViews(arena, root); + const relations = try coord.collectRelationArtifactViews(arena, root); + const lir_roots = try lir.CheckedPipeline.selectPlatformEntrypointRoots(gpa, root.root_requests.runtime_requests); + defer gpa.free(lir_roots); + + var lowered = try lir.CheckedPipeline.lowerCheckedModulesToLir( + gpa, + .{ + .root = check.CheckedArtifact.loweringViewWithRelations(root, relations), + .imports = imports, + }, + .{ + .requests = lir_roots, + .include_static_data_exports = true, + }, + .{ .target_usize = base.target.TargetUsize.u64 }, + ); + defer lowered.deinit(); + + try std.testing.expectEqual(@as(usize, 1), lowered.lir_result.static_data_values.items.len); + try expectStaticDataLiteralPresent(&lowered.lir_result); + + const exports = try static_data_exports.buildProvidedDataExports( + gpa, + .{ + .root = check.CheckedArtifact.loweringViewWithRelations(root, relations), + .imports = imports, + }, + &lowered, + .x64linux, + ); + defer static_data_exports.deinitProvidedDataExports(gpa, exports); + + for (lowered.lir_result.static_data_values.items, 0..) |_, index| { + const static_data_id: lir.LIR.StaticDataId = @enumFromInt(@as(u32, @intCast(index))); + const expected_symbol = try lir.Program.staticDataSymbolName(gpa, static_data_id); + defer gpa.free(expected_symbol); + + for (exports) |static_export| { + if (!std.mem.eql(u8, static_export.symbol_name, expected_symbol)) continue; + + try std.testing.expect(static_export.bytes.len != 0); + try std.testing.expect(static_export.alignment != 0); + try std.testing.expect(static_export.is_global); + try std.testing.expect(!static_export.is_exported); + return; + } + } + return error.StaticDataSymbolNotFound; +} + +test "inline list iter constants lower to internal static data" { + try expectInlineListStaticDataLiteral( + std.testing.allocator, + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\ + \\import pf.Echo + \\ + \\main! = |args| { + \\ var $sum = List.len(args).to_i64_wrap() + \\ for n in [11.I64, 22.I64, 33.I64, 44.I64].iter() { + \\ $sum = $sum + n + \\ } + \\ _ = $sum + \\ Echo.line!("done") + \\ Ok({}) + \\} + , + ); +} + +test "inline list for constants lower to internal static data" { + try expectInlineListStaticDataLiteral( + std.testing.allocator, + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\ + \\import pf.Echo + \\ + \\main! = |args| { + \\ var $sum = List.len(args).to_i64_wrap() + \\ for n in [11.I64, 22.I64, 33.I64, 44.I64] { + \\ $sum = $sum + n + \\ } + \\ _ = $sum + \\ Echo.line!("done") + \\ Ok({}) + \\} + , + ); +} + +fn expectInlineListStaticDataLiteral(gpa: std.mem.Allocator, source: []const u8) HoistedConstantsTestError!void { + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try writeEchoPlatform(tmp_dir.dir); + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "main.roc", + .data = source, + }); + const app_path = try tmp_dir.dir.realPathFileAlloc(std.testing.io, "main.roc", gpa); + defer gpa.free(app_path); + + var arena_impl = collections.SingleThreadArena.init(gpa); + defer arena_impl.deinit(); + const arena = arena_impl.allocator(); + + var builtin_modules = try eval.BuiltinModules.init(gpa); + defer builtin_modules.deinit(); + + var coord = try Coordinator.init( + gpa, + .single_threaded, + 1, + .x64linux, + &builtin_modules, + build_options.compiler_version, + null, + CoreCtx.default(gpa, arena, std.testing.io), + ); + defer coord.deinit(); + coord.enable_hosted_transform = true; + + try coord.start(); + try coord.discoverAppFromPath(arena, .{ .entry_path = app_path }); + try coord.coordinatorLoop(); + try std.testing.expect(!coord.hasUserErrors()); + + try coord.finalizeExecutableArtifacts(); + try std.testing.expect(!coord.hasUserErrors()); + + const root = coord.executableRootCheckedArtifact(); + const imports = try coord.collectImportedArtifactViews(arena, root); + const relations = try coord.collectRelationArtifactViews(arena, root); + const lir_roots = try lir.CheckedPipeline.selectPlatformEntrypointRoots(gpa, root.root_requests.runtime_requests); + defer gpa.free(lir_roots); + + var lowered = try lir.CheckedPipeline.lowerCheckedModulesToLir( + gpa, + .{ + .root = check.CheckedArtifact.loweringViewWithRelations(root, relations), + .imports = imports, + }, + .{ + .requests = lir_roots, + .include_static_data_exports = true, + }, + .{ + .target_usize = base.target.TargetUsize.u32, + .inline_mode = .wrappers, + .list_in_place_map = true, + .tag_reachability = true, + }, + ); + defer lowered.deinit(); + + try std.testing.expectEqual(@as(usize, 1), lowered.lir_result.static_data_values.items.len); + try expectStaticDataLiteralPresent(&lowered.lir_result); +} + test "hoisted constant crash reports original source region" { const gpa = std.testing.allocator; @@ -1493,6 +1748,60 @@ fn countCompileTimeRootKind( return count; } +fn expectStaticDataLiteralPresent(result: *const lir.Program.Result) HoistedConstantsTestError!void { + for (result.store.getCFStmts()) |stmt| { + switch (stmt) { + .assign_literal => |assign| switch (assign.value) { + .static_data => return, + .i64_literal, + .i128_literal, + .f64_literal, + .f32_literal, + .dec_literal, + .str_literal, + .bytes_literal, + .null_ptr, + .proc_ref, + => {}, + }, + .init_uninitialized, + .assign_ref, + .assign_call, + .assign_call_erased, + .assign_packed_erased_fn, + .assign_low_level, + .assign_list, + .assign_struct, + .assign_tag, + .store_struct, + .store_tag, + .set_local, + .debug, + .expect, + .expect_err, + .runtime_error, + .comptime_exhaustiveness_failed, + .comptime_branch_taken, + .switch_stmt, + .switch_initialized_payload, + .str_match, + .str_match_set, + .loop_continue, + .loop_break, + .join, + .jump, + .ret, + .crash, + .incref, + .decref, + .decref_if_initialized, + .free, + => {}, + } + } + return error.StaticDataLiteralNotFound; +} + fn storedI64( artifact: check.CheckedArtifact.ImportedModuleView, entry: check.CheckedArtifact.HoistedConstEntry, diff --git a/src/compile/test/lower_to_lir_harness.zig b/src/compile/test/lower_to_lir_harness.zig index 36bf289ebb9..c890f67c763 100644 --- a/src/compile/test/lower_to_lir_harness.zig +++ b/src/compile/test/lower_to_lir_harness.zig @@ -68,6 +68,7 @@ pub const LirInspectFn = *const fn ( /// Options controlling how the harness lowers an app to LIR. pub const LirLoweringOptions = struct { target_usize: base.target.TargetUsize = base.target.TargetUsize.native, + inline_mode: lir.CheckedPipeline.InlineMode = .none, list_in_place_map: bool = false, }; @@ -84,6 +85,18 @@ pub fn expectAppPathLowersToLir(app_path: []const u8) LowerToLirHarnessError!voi try lowerAppPathToLir(std.testing.allocator, app_path, null, .{}, null); } +/// Lower an app at `app_path` to LIR, then run a focused invariant check +/// against the actual lowered store and layout store. +pub fn expectAppPathLirInspection(app_path: []const u8, inspect: LirInspectFn) LowerToLirHarnessError!void { + try lowerAppPathToLir(std.testing.allocator, app_path, null, .{}, inspect); +} + +/// Lower an app at `app_path` to LIR with explicit lowering options, then run +/// a focused invariant check against the actual lowered store and layout store. +pub fn runAppPathLirInspection(app_path: []const u8, opts: LirLoweringOptions, inspect: LirInspectFn) LowerToLirHarnessError!void { + try lowerAppPathToLir(std.testing.allocator, app_path, null, opts, inspect); +} + /// Lower an app whose body is `app_body` to LIR, then run a focused invariant /// check against the actual lowered store and layout store. pub fn expectLirInspection(app_body: []const u8, inspect: LirInspectFn) LowerToLirHarnessError!void { @@ -240,7 +253,11 @@ fn lowerAppPathToLir( .imports = imports, }, .{ .requests = lir_roots }, - .{ .target_usize = opts.target_usize, .list_in_place_map = opts.list_in_place_map }, + .{ + .target_usize = opts.target_usize, + .inline_mode = opts.inline_mode, + .list_in_place_map = opts.list_in_place_map, + }, ); defer lowered.deinit(); diff --git a/src/compile/test/platform_box_update_lir_test.zig b/src/compile/test/platform_box_update_lir_test.zig new file mode 100644 index 00000000000..1a3c924fe39 --- /dev/null +++ b/src/compile/test/platform_box_update_lir_test.zig @@ -0,0 +1,98 @@ +//! Regression coverage for platform-provided boxed model update wrappers. + +const std = @import("std"); +const layout = @import("layout"); +const lir = @import("lir"); +const GuardedList = lir.LirStore.GuardedList; + +const harness = @import("lower_to_lir_harness.zig"); + +test "platform boxed update wrapper prepares in-place update" { + try harness.runAppPathLirInspection("test/int/app.roc", .{ .inline_mode = .wrappers }, expectBoxPrepareUpdate); +} + +fn expectBoxPrepareUpdate(store: *const lir.LirStore, _: *const layout.Store) harness.LowerToLirHarnessError!void { + var prepare_update_count: usize = 0; + + for (0..store.getProcSpecs().len) |index| { + const proc_id: lir.LIR.LirProcSpecId = @enumFromInt(@as(u32, @intCast(index))); + const proc = store.getProcSpec(proc_id); + const body = proc.body orelse continue; + + var work = std.ArrayList(lir.LIR.CFStmtId).empty; + defer work.deinit(std.testing.allocator); + var visited = std.AutoHashMap(lir.LIR.CFStmtId, void).init(std.testing.allocator); + defer visited.deinit(); + + try work.append(std.testing.allocator, body); + while (work.pop()) |stmt_id| { + const entry = try visited.getOrPut(stmt_id); + if (entry.found_existing) continue; + + switch (store.getCFStmt(stmt_id)) { + inline .assign_ref, + .assign_literal, + .init_uninitialized, + .assign_call, + .assign_call_erased, + .assign_packed_erased_fn, + .assign_list, + .assign_struct, + .assign_tag, + .store_struct, + .store_tag, + .set_local, + .debug, + .expect, + .comptime_branch_taken, + .incref, + .decref, + .decref_if_initialized, + .free, + => |stmt| try work.append(std.testing.allocator, stmt.next), + .assign_low_level => |stmt| { + if (stmt.op == .box_prepare_update) prepare_update_count += 1; + try work.append(std.testing.allocator, stmt.next); + }, + .switch_stmt => |stmt| { + if (stmt.continuation) |continuation| try work.append(std.testing.allocator, continuation); + const cases = store.getCFSwitchBranches(stmt.branches); + for (0..cases.len) |case_index| { + try work.append(std.testing.allocator, GuardedList.at(cases, case_index).body); + } + try work.append(std.testing.allocator, stmt.default_branch); + }, + .switch_initialized_payload => |stmt| { + try work.append(std.testing.allocator, stmt.initialized_branch); + try work.append(std.testing.allocator, stmt.uninitialized_branch); + }, + .str_match => |stmt| { + try work.append(std.testing.allocator, stmt.on_match); + try work.append(std.testing.allocator, stmt.on_miss); + }, + .str_match_set => |stmt| { + const arms = store.getStrMatchArms(stmt.arms); + for (0..arms.len) |arm_index| { + try work.append(std.testing.allocator, GuardedList.at(arms, arm_index).on_match); + } + try work.append(std.testing.allocator, stmt.on_miss); + }, + .join => |stmt| { + try work.append(std.testing.allocator, stmt.body); + try work.append(std.testing.allocator, stmt.remainder); + }, + .runtime_error, + .comptime_exhaustiveness_failed, + .loop_continue, + .loop_break, + .jump, + .ret, + .crash, + .expect_err, + => {}, + } + } + } + + try std.testing.expectEqual(@as(usize, 1), prepare_update_count); +} diff --git a/src/docs/extract.zig b/src/docs/extract.zig index ab420b26963..670d7799dcb 100644 --- a/src/docs/extract.zig +++ b/src/docs/extract.zig @@ -953,7 +953,7 @@ fn extractWhereMethodSignature( const signature = try allocDocType(gpa, .{ .function = .{ .args = args, .ret = ret, - .effectful = false, + .effectful = method.effectful, } }); args_moved = true; ret_moved = true; diff --git a/src/eval/compiler_host.zig b/src/eval/compiler_host.zig index 8593ed9e3f1..bec74071a49 100644 --- a/src/eval/compiler_host.zig +++ b/src/eval/compiler_host.zig @@ -14,6 +14,7 @@ const Allocation = struct { allocator: std.mem.Allocator, allocations: std.AutoHashMap(usize, Allocation), +debug_messages: std.ArrayList([]u8) = .empty, roc_ops: ?RocOps = null, crash_message: ?[]u8 = null, expect_message: ?[]u8 = null, @@ -27,6 +28,8 @@ pub fn init(allocator: std.mem.Allocator) CompilerHost { pub fn deinit(self: *CompilerHost) void { self.freeRemainingAllocations(); + self.clearDebugMessages(); + self.debug_messages.deinit(self.allocator); self.allocations.deinit(); if (self.crash_message) |msg| self.allocator.free(msg); if (self.expect_message) |msg| self.allocator.free(msg); @@ -50,6 +53,19 @@ pub fn ops(self: *CompilerHost) *RocOps { return &self.roc_ops.?; } +/// Return `dbg` messages captured during the current interpreter evaluation. +pub fn debugMessages(self: *const CompilerHost) []const []const u8 { + return self.debug_messages.items; +} + +/// Free and clear all captured `dbg` messages. +pub fn clearDebugMessages(self: *CompilerHost) void { + for (self.debug_messages.items) |msg| { + self.allocator.free(msg); + } + self.debug_messages.clearRetainingCapacity(); +} + fn rocAlloc(roc_ops: *RocOps, length: usize, alignment: usize) callconv(.c) ?*anyopaque { const self: *CompilerHost = @ptrCast(@alignCast(roc_ops.env)); const allocation: Allocation = .{ .size = length, .alignment = alignment }; @@ -95,7 +111,14 @@ fn rocRealloc(roc_ops: *RocOps, ptr: *anyopaque, new_length: usize, alignment: u return @ptrCast(new_ptr); } -fn rocDbg(_: *RocOps, _: [*]const u8, _: usize) callconv(.c) void {} +fn rocDbg(roc_ops: *RocOps, bytes: [*]const u8, len: usize) callconv(.c) void { + const self: *CompilerHost = @ptrCast(@alignCast(roc_ops.env)); + const owned = self.allocator.dupe(u8, bytes[0..len]) catch return; + self.debug_messages.append(self.allocator, owned) catch { + self.allocator.free(owned); + return; + }; +} fn rocExpectFailed(roc_ops: *RocOps, bytes: [*]const u8, len: usize) callconv(.c) void { const self: *CompilerHost = @ptrCast(@alignCast(roc_ops.env)); diff --git a/src/eval/interpreter.zig b/src/eval/interpreter.zig index d26ef2c8239..f02de4170e5 100644 --- a/src/eval/interpreter.zig +++ b/src/eval/interpreter.zig @@ -85,7 +85,7 @@ const longjmp = sljmp.longjmp; /// Failed inline `expect` observed during one interpreter evaluation. pub const ExpectFailure = struct { message: []const u8, - loc: base.SourceLoc, + region: base.Region, }; /// Environment for interpreter-managed RocOps forwarding. @@ -168,12 +168,12 @@ const InterpreterRocEnv = struct { self.expect_failures.clearRetainingCapacity(); } - fn recordExpectFailure(self: *InterpreterRocEnv, msg: []const u8, loc: base.SourceLoc) Allocator.Error!void { + fn recordExpectFailure(self: *InterpreterRocEnv, msg: []const u8, region: base.Region) Allocator.Error!void { const owned_msg = try self.allocator.dupe(u8, msg); errdefer self.allocator.free(owned_msg); try self.expect_failures.append(self.allocator, .{ .message = owned_msg, - .loc = loc, + .region = region, }); } @@ -1494,6 +1494,22 @@ pub const Interpreter = struct { @intFromEnum(self.store.getLocal(assign.target).layout_idx), }, ), + .store_struct => |assign| debugPrint( + " stmt {d}: {any} store_layout={d}\n", + .{ + @intFromEnum(current), + stmt, + @intFromEnum(assign.struct_layout), + }, + ), + .store_tag => |assign| debugPrint( + " stmt {d}: {any} store_layout={d}\n", + .{ + @intFromEnum(current), + stmt, + @intFromEnum(assign.tag_layout), + }, + ), .set_local => |assign| debugPrint( " stmt {d}: {any} target_layout={d} target_layout_data={any}\n", .{ @@ -1516,6 +1532,8 @@ pub const Interpreter = struct { .assign_list => |assign| assign.next, .assign_struct => |assign| assign.next, .assign_tag => |assign| assign.next, + .store_struct => |assign| assign.next, + .store_tag => |assign| assign.next, .set_local => |assign| assign.next, .debug => |stmt_next| stmt_next.next, .expect => |stmt_next| stmt_next.next, @@ -1897,6 +1915,24 @@ pub const Interpreter = struct { )); current = assign.next; }, + .store_struct => |assign| { + const dest = try self.getLocalChecked(frame, assign.dest); + const value = try self.evalStructLiteral(frame, assign.fields, assign.struct_layout); + _ = try self.evalPtrStore(dest, value, assign.struct_layout); + current = assign.next; + }, + .store_tag => |assign| { + const dest = try self.getLocalChecked(frame, assign.dest); + const value = try self.evalTagLiteral( + frame, + assign.variant_index, + assign.discriminant, + assign.payload, + assign.tag_layout, + ); + _ = try self.evalPtrStore(dest, value, assign.tag_layout); + current = assign.next; + }, .set_local => |assign| { const target_layout = self.store.getLocal(assign.target).layout_idx; const normalized = try self.coerceExplicitRefValueToLayout( @@ -1923,7 +1959,7 @@ pub const Interpreter = struct { self.store.getLocal(cond_local).layout_idx, ); if (cond_value == 0) { - try self.roc_env.recordExpectFailure("expect failed", self.store.stmtLoc(current)); + try self.roc_env.recordExpectFailure("expect failed", self.store.stmtRegion(current)); self.roc_ops.expectFailed("expect failed"); } current = expect_stmt.next; @@ -2243,9 +2279,11 @@ pub const Interpreter = struct { stack.append(self.evalAllocator(), assign.next) catch return; }, .assign_packed_erased_fn => |assign| { - debugPrint(" {d}: assign_packed_erased_fn target={d} next={d}\n", .{ + debugPrint(" {d}: assign_packed_erased_fn target={d} reuse={?d} unique={} next={d}\n", .{ @intFromEnum(stmt_id), @intFromEnum(assign.target), + if (assign.reuse) |reuse| @intFromEnum(reuse) else null, + assign.reuse_unique, @intFromEnum(assign.next), }); stack.append(self.evalAllocator(), assign.next) catch return; @@ -2297,6 +2335,31 @@ pub const Interpreter = struct { }); stack.append(self.evalAllocator(), assign.next) catch return; }, + .store_struct => |assign| { + debugPrint(" {d}: store_struct dest={d} fields=", .{ + @intFromEnum(stmt_id), + @intFromEnum(assign.dest), + }); + const fields = self.store.getLocalSpan(assign.fields); + for (0..fields.len) |index| { + const field_local = GuardedList.at(fields, index); + debugPrint("{d} ", .{@intFromEnum(field_local)}); + } + debugPrint("next={d}\n", .{ + @intFromEnum(assign.next), + }); + stack.append(self.evalAllocator(), assign.next) catch return; + }, + .store_tag => |assign| { + debugPrint(" {d}: store_tag dest={d} variant={d} discrim={d} next={d}\n", .{ + @intFromEnum(stmt_id), + @intFromEnum(assign.dest), + assign.variant_index, + assign.discriminant, + @intFromEnum(assign.next), + }); + stack.append(self.evalAllocator(), assign.next) catch return; + }, .set_local => |assign| { debugPrint(" {d}: set_local target={d} value={d} next={d}\n", .{ @intFromEnum(stmt_id), @@ -2713,6 +2776,10 @@ pub const Interpreter = struct { .f32_literal => |value| self.evalF32Literal(value), .dec_literal => |value| self.evalDecLiteral(value), .str_literal => |idx| self.evalStrLiteral(idx), + .static_data => self.invariantFailed( + "LIR/interpreter invariant violated: static data literal reached compile-time execution", + .{}, + ), .bytes_literal => |idx| self.evalBytesLiteral(idx, target_layout), .null_ptr => self.evalNullPtrLiteral(), .proc_ref => |proc_id| self.evalProcRefLiteral(proc_id), @@ -2969,7 +3036,27 @@ pub const Interpreter = struct { } } const capture_size = erased_callable_context_capture_offset + capture_value_size; - const data_ptr = try self.allocRocDataWithRc( + const data_ptr = if (assign.reuse) |reuse_local| blk: { + const reuse_value = try self.getLocalChecked(frame, reuse_local); + const reuse_ptr = self.readBoxedDataPointer(reuse_value) orelse { + return self.invariantFailedError( + "LIR/interpreter invariant violated: erased callable repack reuse had null payload", + .{}, + ); + }; + if (assign.reuse_unique or builtins.utils.isUnique(reuse_ptr, &self.roc_ops)) { + self.performErasedCallableFinalDrop(reuse_ptr, .decref, 1); + break :blk reuse_ptr; + } + + const fresh = try self.allocRocDataWithRc( + builtins.erased_callable.payloadSize(capture_size), + builtins.erased_callable.payload_alignment, + builtins.erased_callable.allocation_has_refcounted_children, + ); + builtins.erased_callable.decref(reuse_ptr, &self.roc_ops); + break :blk fresh; + } else try self.allocRocDataWithRc( builtins.erased_callable.payloadSize(capture_size), builtins.erased_callable.payload_alignment, builtins.erased_callable.allocation_has_refcounted_children, @@ -5837,6 +5924,7 @@ pub const Interpreter = struct { // ── Box ops ── .box_box => try self.evalBoxBox(args[0], ll.ret_layout), .box_unbox => try self.evalBoxUnbox(args[0], ll.ret_layout), + .box_prepare_update => try self.evalBoxPrepareUpdate(args[0], ll.ret_layout, ll.unique_args), .erased_capture_load => try self.evalErasedCaptureLoad(args[0], ll.ret_layout), .ptr_alloca => try self.evalPtrAlloca(ll.ret_layout), .box_alloc_zeroed => try self.evalBoxAllocZeroed(ll.ret_layout), @@ -7877,6 +7965,57 @@ pub const Interpreter = struct { return result; } + fn evalBoxPrepareUpdate(self: *LirInterpreter, boxed: Value, ret_layout: layout_mod.Idx, unique_args: u64) Error!Value { + const ret_layout_val = self.layout_store.getLayout(ret_layout); + switch (ret_layout_val.tag) { + .box_of_zst => return try self.allocBoxOfZstValue(ret_layout), + .box => { + const box_info = self.boxAllocInfo(ret_layout_val); + const data_ptr = self.readBoxedDataPointer(boxed) orelse { + const result = try self.alloc(ret_layout); + self.writeBoxedDataPointer(result, null); + return result; + }; + + if (box_info.elem_size == 0 or (unique_args & 1) != 0 or builtins.utils.isUnique(data_ptr, &self.roc_ops)) { + const result = try self.alloc(ret_layout); + self.writeBoxedDataPointer(result, data_ptr); + return result; + } + + const fresh = try self.allocRocDataWithRc( + box_info.elem_size, + box_info.elem_alignment, + box_info.contains_rc, + ); + @memcpy(fresh[0..box_info.elem_size], data_ptr[0..box_info.elem_size]); + + if (box_info.contains_rc) { + self.performBuiltinInternalRc( + "interpreter.box_prepare_update.payload_incref", + .incref, + .{ .ptr = fresh }, + box_info.elem_layout, + 1, + ); + } + + self.performBuiltinInternalRc( + "interpreter.box_prepare_update.input_decref", + .decref, + boxed, + ret_layout, + 1, + ); + + const result = try self.alloc(ret_layout); + self.writeBoxedDataPointer(result, fresh); + return result; + }, + else => return error.RuntimeError, + } + } + fn evalErasedCaptureLoad(self: *LirInterpreter, capture_ptr: Value, ret_layout: layout_mod.Idx) Error!Value { if (ret_layout == .zst) return Value.zst; diff --git a/src/eval/test/eval_comptime_finalization_tests.zig b/src/eval/test/eval_comptime_finalization_tests.zig index 2489f2a16e5..b266acacd3d 100644 --- a/src/eval/test/eval_comptime_finalization_tests.zig +++ b/src/eval/test/eval_comptime_finalization_tests.zig @@ -89,6 +89,33 @@ const dbg_does_not_halt = \\main = x ; +const unused_top_level_dbg_does_not_halt = + \\unused : I64 + \\unused = { + \\ dbg 40 + \\ 1.I64 + \\} + \\main = 42 +; + +const unused_top_level_expect_failure = + \\unused : I64 + \\unused = { + \\ expect False + \\ 1.I64 + \\} + \\main = 42 +; + +const unused_top_level_crash = + \\unused : I64 + \\unused = { + \\ crash "unused top-level constant crash" + \\ 1.I64 + \\} + \\main = 42 +; + const folded_multiply = \\x = 6 * 7 \\main = x @@ -728,6 +755,30 @@ const import_crash_module = \\} ; +const import_unused_expect_module = + \\module [safe] + \\ + \\hidden_bad : I64 + \\hidden_bad = { + \\ expect False + \\ 0.I64 + \\} + \\ + \\safe = 42 +; + +const import_unused_crash_module = + \\module [safe] + \\ + \\hidden_bad : I64 + \\hidden_bad = { + \\ crash "imported unused top-level constant crash" + \\ 0.I64 + \\} + \\ + \\safe = 42 +; + const crash_other_defs = \\good = 42 \\bad : {} -> I64 @@ -769,6 +820,11 @@ pub const tests = [_]TestCase{ .{ .name = "comptime eval - crash does not halt other defs", .source_kind = .module, .source = crash_other_defs, .expected = .{ .inspect_str = "42.0" } }, .{ .name = "comptime eval - expect failure does not halt evaluation", .source_kind = .module, .source = expect_failure, .expected = .{ .problem = {} } }, .{ .name = "comptime eval - dbg does not halt evaluation", .source_kind = .module, .source = dbg_does_not_halt, .expected = .{ .inspect_str = "42.0" } }, + .{ .name = "comptime eval - unused top-level dbg still evaluates", .source_kind = .module, .source = unused_top_level_dbg_does_not_halt, .expected = .{ .inspect_str = "42.0" } }, + .{ .name = "comptime eval - unused top-level constant expect failure is reported", .source_kind = .module, .source = unused_top_level_expect_failure, .expected = .{ .problem = {} } }, + .{ .name = "comptime eval - unused top-level constant crash is reported", .source_kind = .module, .source = unused_top_level_crash, .expected = .{ .problem = {} } }, + .{ .name = "comptime eval - imported unused top-level expect failure is reported", .source_kind = .module, .imports = &.{.{ .name = "Util", .source = import_unused_expect_module }}, .source = "import Util\nmain = Util.safe", .expected = .{ .problem = {} } }, + .{ .name = "comptime eval - imported unused top-level crash is reported", .source_kind = .module, .imports = &.{.{ .name = "Util", .source = import_unused_crash_module }}, .source = "import Util\nmain = Util.safe", .expected = .{ .problem = {} } }, .{ .name = "comptime eval - crash in first def does not halt other defs", .source_kind = .module, .source = crash_first_other_defs, .expected = .{ .inspect_str = "42.0" } }, .{ .name = "comptime eval - crash halts within single def", .source = crash_now, .expected = .{ .crash = {} } }, .{ .name = "comptime eval - constant folding multiplication", .source_kind = .module, .source = folded_multiply, .expected = .{ .inspect_str = "42.0" } }, diff --git a/src/eval/test/eval_iter_alloc_tests.zig b/src/eval/test/eval_iter_alloc_tests.zig new file mode 100644 index 00000000000..235714b4760 --- /dev/null +++ b/src/eval/test/eval_iter_alloc_tests.zig @@ -0,0 +1,231 @@ +//! Zero-allocation gate for Iter. +//! +//! Every iterator case asserts `max_allocations = 0`: a statically-known +//! iterator chain must perform ZERO heap allocations, regardless of how it is +//! consumed. Range sources are used deliberately — a range's state is two +//! integers, so there is no list-build allocation to confound the measurement; +//! any allocation is the iterator machinery itself. +//! +//! The observed output is a static string literal chosen by comparing the +//! iterator's computed value to its expected value ("ok" iff correct). String +//! literals are compile-time constants, so the observation itself allocates +//! nothing — the iterator's fold is the only possible allocator. This is +//! confirmed by the two canary cases below, which must be GREEN (0 allocations). +//! +//! The iterator cases are RED on the recursive-nominal representation (the +//! iterator boxes a successor state per step). They turn GREEN only when the +//! internal representation carries a statically-known chain by value. +//! `allocations_at_most` counts cumulative roc_alloc/roc_realloc, so a per-step +//! allocate/free loop is caught even though it leaves the live heap near zero. + +const TestCase = @import("parallel_runner.zig").TestCase; + +/// Eval test cases that require statically-known Iter chains to run without +/// heap allocation. +pub const tests = [_]TestCase{ + // --- Canaries: prove the observation pattern (literal + arithmetic + + // branch) allocates nothing, so a nonzero count is the iterator's fault. --- + .{ + .name = "iter alloc canary: bare string literal is zero-alloc", + .source = "\"ok\"", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc canary: arithmetic and branch to a literal is zero-alloc", + .source = "if 2.U64 + 1 == 3 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + + // --- Expression sources: base + single adapters over a range --- + .{ + .name = "iter alloc: range fold is zero-alloc", + .source = "if Iter.fold(Iter.exclusive_range(0.U64, 5), 0.U64, |a, b| a + b) == 10 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: range map fold is zero-alloc", + .source = "if Iter.fold(Iter.map(Iter.exclusive_range(0.U64, 5), |n| n + 1), 0.U64, |a, b| a + b) == 15 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: range keep_if fold is zero-alloc", + .source = "if Iter.fold(Iter.keep_if(Iter.exclusive_range(0.U64, 6), |n| n > 2), 0.U64, |a, b| a + b) == 12 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: range drop_if fold is zero-alloc", + .source = "if Iter.fold(Iter.drop_if(Iter.exclusive_range(0.U64, 6), |n| n <= 2), 0.U64, |a, b| a + b) == 12 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: range take_first fold is zero-alloc", + .source = "if Iter.fold(Iter.take_first(Iter.exclusive_range(0.U64, 6), 3), 0.U64, |a, b| a + b) == 3 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: range drop_first fold is zero-alloc", + .source = "if Iter.fold(Iter.drop_first(Iter.exclusive_range(0.U64, 6), 3), 0.U64, |a, b| a + b) == 12 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: range concat fold is zero-alloc", + .source = "if Iter.fold(Iter.concat(Iter.exclusive_range(0.U64, 3), Iter.exclusive_range(3.U64, 6)), 0.U64, |a, b| a + b) == 15 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: range append fold is zero-alloc", + .source = "if Iter.fold(Iter.append(Iter.exclusive_range(0.U64, 5), 5), 0.U64, |a, b| a + b) == 15 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + // concat over a `single` combines two differently-typed minted iterators + // by value; `single`'s successor and concat's exhausted first must each + // keep one monomorphic type, else the chain is un-representable flat. + .name = "iter alloc: range concat single fold is zero-alloc", + .source = "if Iter.fold(Iter.concat(Iter.exclusive_range(0.U64, 3), Iter.single(9)), 0.U64, |a, b| a + b) == 12 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: range single fold is zero-alloc", + .source = "if Iter.fold(Iter.single(7.U64), 0.U64, |a, b| a + b) == 7 { \"ok\" } else { \"bad\" }", + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + + // --- `for`-loop driver over a minted chain. A `for` sinks the consuming + // loop into the chain and rebases the step's inline captures; the append + // step re-feeds its own inner-iterator capture slot with the destructured + // successor `rest`, so a driver that drops that operand freezes the inner + // iterator at its head and the loop never terminates. `fold` does not + // exercise this path, so these `for` cases are the gate for it. --- + .{ + .name = "iter alloc: range append for-loop is zero-alloc", + .source = + \\{ + \\ var sum = 0.U64 + \\ for x in Iter.append(Iter.exclusive_range(0.U64, 5), 5) { + \\ sum = sum + x + \\ } + \\ if sum == 15 { "ok" } else { "bad" } + \\} + , + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: list append append for-loop only allocates base list", + .source = + \\{ + \\ base_points = [ + \\ { x: 11.I64, y: 2.I64 }, { x: 13, y: 3 }, + \\ { x: 3, y: 5 }, { x: 11, y: 6 }, + \\ { x: 9, y: 8 }, { x: 5, y: 9 }, + \\ { x: 7, y: 10 }, { x: 5, y: 12 }, + \\ ].iter() + \\ collision_points = base_points.append({ x: 2, y: 1 }).append({ x: 7, y: 1 }) + \\ var sum = 0.I64 + \\ for { x, y } in collision_points { + \\ sum = sum + x + y + \\ } + \\ if sum == 130 { "ok" } else { "bad" } + \\} + , + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 1, .optimized = true } }, + }, + .{ + .name = "iter alloc: range map for-loop is zero-alloc", + .source = + \\{ + \\ var sum = 0.U64 + \\ for x in Iter.map(Iter.exclusive_range(0.U64, 5), |n| n + 1) { + \\ sum = sum + x + \\ } + \\ if sum == 15 { "ok" } else { "bad" } + \\} + , + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: captured range map folds are zero-alloc", + .source = + \\{ + \\ offset = 1.U64 + \\ record = { big: 10.U64, small: 3.U64 } + \\ small = Iter.fold(Iter.map(Iter.exclusive_range(0.U64, 5), |n| n + offset), 0.U64, |a, b| a + b) + \\ large = Iter.fold(Iter.map(Iter.exclusive_range(0.U64, 5), |n| n + record.big + record.small), 0.U64, |a, b| a + b) + \\ if small == 15 and large == 75 { "ok" } else { "bad" } + \\} + , + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + .{ + .name = "iter alloc: finite deep static map chain is zero-alloc", + .source = + \\if Iter.fold( + \\ Iter.map( + \\ Iter.map( + \\ Iter.map( + \\ Iter.map( + \\ Iter.map( + \\ Iter.map( + \\ Iter.map( + \\ Iter.map( + \\ Iter.map( + \\ Iter.map( + \\ Iter.exclusive_range(0.U64, 5), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ |n| n + 1, + \\ ), + \\ 0.U64, + \\ |a, b| a + b, + \\) == 60 { "ok" } else { "bad" } + , + .expected = .{ .allocations_at_most = .{ .output = "ok", .max_allocations = 0 } }, + }, + + // Escaping cases (iterator returned / passed / branch-merged) require + // module-level function definitions, which the runtime allocation path does + // not support. They are gated statically by `box_box_count == 0` over + // reachable procs in lir_inline_test.zig ("iter alloc static: …"). + + // Behavioral aliasing guard — the allocation gate is INVERTED for this bug. + // A list held by a live iterator must not be seen as unique: if in-place + // mutation of the same list fired, it would corrupt the iterator's view AND + // lower the allocation count (the gate would go greener on a miscompile). + // Here `it` holds `xs`, so `List.map(xs, ...)` must not mutate `xs` in + // place; the fold over `it` must observe the original elements. a = sum(xs) + // = 15, b = sum(2*xs) = 30, result = a*1000 + b = 15030. A wrong `a` (e.g. + // 30030) means the shared buffer was mutated under the live iterator. This + // is a correctness assertion, not an allocation one. + .{ + .name = "iter alloc guard: list held by a live iterator is not mutated in place", + .source_kind = .module, + .source = + \\main : I64 + \\main = { + \\ xs = [1.I64, 2, 3, 4, 5] + \\ it = List.iter(xs) + \\ doubled = List.map(xs, |n| n * 2) + \\ a = Iter.fold(it, 0.I64, |s, n| s + n) + \\ b = List.fold(doubled, 0.I64, |s, n| s + n) + \\ a * 1000 + b + \\} + , + .expected = .{ .inspect_str = "15030" }, + }, +}; diff --git a/src/eval/test/eval_tests.zig b/src/eval/test/eval_tests.zig index c0419c0460d..de20369e771 100644 --- a/src/eval/test/eval_tests.zig +++ b/src/eval/test/eval_tests.zig @@ -12,6 +12,7 @@ const interpreter_style_tests = @import("eval_interpreter_style_tests.zig"); const low_level_tests = @import("eval_low_level_tests.zig"); const polymorphism_tests = @import("eval_polymorphism_tests.zig"); const recursive_data_tests = @import("eval_recursive_data_tests.zig"); +const iter_alloc_tests = @import("eval_iter_alloc_tests.zig"); /// All eval test cases, consumed by the parallel runner. /// @@ -3648,6 +3649,108 @@ const core_tests = [_]TestCase{ .source = "Iter.size_hint([1.I64, 2, 3].iter().append(4))", .expected = .{ .inspect_str = "Known(4)" }, }, + .{ + .name = "inspect: Iter.next steps appended iterator in order", + .source = + \\{ + \\ first = Iter.next([1.I64, 2].iter().append(3)) + \\ match first { + \\ One({ item, rest }) => [item].concat(Iter.fold(rest, [], |acc, n| acc.append(n))) + \\ _ => [] + \\ } + \\} + , + .expected = .{ .inspect_str = "[1, 2, 3]" }, + }, + .{ + .name = "inspect: Iter.next reuses public iterator values", + .source = + \\{ + \\ iter = [1.I64, 2].iter() + \\ + \\ first = match Iter.next(iter) { + \\ One({ item, .. }) => item + \\ _ => 0 + \\ } + \\ + \\ second = match Iter.next(iter) { + \\ One({ item, .. }) => item + \\ _ => 0 + \\ } + \\ + \\ (first, second) + \\} + , + .expected = .{ .inspect_str = "(1, 1)" }, + }, + .{ + .name = "inspect: for loop does not mutate aliased public iterator", + .source = + \\{ + \\ iter = [1.I64, 2].iter() + \\ saved = iter + \\ + \\ var $sum = 0.I64 + \\ for item in iter { + \\ $sum = $sum + item + \\ } + \\ + \\ saved_first = match Iter.next(saved) { + \\ One({ item, .. }) => item + \\ _ => 0 + \\ } + \\ + \\ ($sum, saved_first) + \\} + , + .expected = .{ .inspect_str = "(3, 1)" }, + }, + .{ + .name = "inspect: escaped Iter.next rest keeps public meaning", + .source = + \\{ + \\ iter = [1.I64, 2, 3].iter() + \\ + \\ rest = match Iter.next(iter) { + \\ One({ rest, .. }) => rest + \\ _ => iter + \\ } + \\ + \\ match Iter.next(rest) { + \\ One({ item, .. }) => item + \\ _ => 0 + \\ } + \\} + , + .expected = .{ .inspect_str = "2" }, + }, + .{ + .name = "for loop over appended iterator", + .source = + \\{ + \\ iter = [1.I64, 2].iter().append(3).append(4) + \\ var $sum = 0.I64 + \\ for item in iter { + \\ $sum = $sum + item + \\ } + \\ $sum + \\} + , + .expected = .{ .inspect_str = "10" }, + }, + .{ + .name = "for loop over inline appended iterator", + .source = + \\{ + \\ var $sum = 0.I64 + \\ for item in [1.I64, 2].iter().append(3).append(4) { + \\ $sum = $sum + item + \\ } + \\ $sum + \\} + , + .expected = .{ .inspect_str = "10" }, + }, .{ .name = "inspect: Iter.keep_if emits skip with rest iterator", .source = @@ -5000,4 +5103,4 @@ const core_tests = [_]TestCase{ }, }; -pub const tests = core_tests ++ comptime_finalization_tests.tests ++ crypto_tests.tests ++ closure_recursion_tests.tests ++ recursive_data_tests.tests ++ low_level_tests.tests ++ highest_lowest_tests.tests ++ polymorphism_tests.tests ++ issue_tests.tests ++ interpreter_style_tests.tests ++ regression_repros.tests ++ trmc_tests.tests; +pub const tests = core_tests ++ comptime_finalization_tests.tests ++ crypto_tests.tests ++ closure_recursion_tests.tests ++ recursive_data_tests.tests ++ low_level_tests.tests ++ highest_lowest_tests.tests ++ polymorphism_tests.tests ++ issue_tests.tests ++ interpreter_style_tests.tests ++ regression_repros.tests ++ trmc_tests.tests ++ iter_alloc_tests.tests; diff --git a/src/eval/test/lir_inline_test.zig b/src/eval/test/lir_inline_test.zig index 4da9f6afb71..ae72da27102 100644 --- a/src/eval/test/lir_inline_test.zig +++ b/src/eval/test/lir_inline_test.zig @@ -85,8 +85,10 @@ fn lowerModule( } const LowerModuleOptions = struct { + checked_module_state: lir.CheckedPipeline.CheckedModuleState = .complete, inline_expects: lir.CheckedPipeline.InlineExpectMode = .run, proc_debug_names: bool = false, + tag_reachability: bool = false, imports: []const helpers.ModuleSource = &.{}, }; @@ -122,9 +124,11 @@ fn lowerModuleWithOptions( .{ .requests = resources.checked_artifact.root_requests.requests }, .{ .target_usize = base.target.TargetUsize.native, + .checked_module_state = options.checked_module_state, .inline_mode = inline_mode, .inline_expects = options.inline_expects, .proc_debug_names = options.proc_debug_names, + .tag_reachability = options.tag_reachability, }, ); errdefer lowered.deinit(); @@ -796,6 +800,8 @@ fn collectAssignCallProcs( .assign_ref => |stmt| try work.append(allocator, stmt.next), .assign_literal => |stmt| try work.append(allocator, stmt.next), .init_uninitialized => |stmt| try work.append(allocator, stmt.next), + .store_struct => |stmt| try work.append(allocator, stmt.next), + .store_tag => |stmt| try work.append(allocator, stmt.next), .assign_call => |stmt| { try calls.append(allocator, stmt.proc); try work.append(allocator, stmt.next); @@ -862,8 +868,21 @@ const ProcShape = struct { arg_count: usize, direct_call_count: usize = 0, erased_call_count: usize = 0, + packed_erased_fn_count: usize = 0, low_level_count: usize = 0, + list_len_count: usize = 0, + list_get_unsafe_count: usize = 0, + list_with_capacity_count: usize = 0, + list_append_unsafe_count: usize = 0, + list_reserve_count: usize = 0, str_count_utf8_bytes_count: usize = 0, + str_concat_count: usize = 0, + box_box_count: usize = 0, + box_unbox_count: usize = 0, + box_prepare_update_count: usize = 0, + ptr_cast_count: usize = 0, + ptr_load_count: usize = 0, + ptr_store_count: usize = 0, self_call_count: usize = 0, switch_count: usize = 0, str_match_set_count: usize = 0, @@ -872,6 +891,8 @@ const ProcShape = struct { jump_count: usize = 0, struct_assign_count: usize = 0, tag_assign_count: usize = 0, + store_struct_count: usize = 0, + store_tag_count: usize = 0, }; fn collectProcShape( @@ -879,112 +900,7 @@ fn collectProcShape( lowered: *const lir.CheckedPipeline.LoweredProgram, proc_id: LIR.LirProcSpecId, ) TestError!ProcShape { - const proc = lowered.lir_result.store.getProcSpec(proc_id); - var shape = ProcShape{ - .arg_count = lowered.lir_result.store.getLocalSpan(proc.args).len, - }; - - const body = proc.body orelse return shape; - - var work = std.ArrayList(LIR.CFStmtId).empty; - defer work.deinit(allocator); - try work.append(allocator, body); - - var visited = std.AutoHashMap(LIR.CFStmtId, void).init(allocator); - defer visited.deinit(); - - while (work.pop()) |stmt_id| { - const visited_entry = try visited.getOrPut(stmt_id); - if (visited_entry.found_existing) continue; - - switch (lowered.lir_result.store.getCFStmt(stmt_id)) { - .assign_ref => |stmt| try work.append(allocator, stmt.next), - .assign_literal => |stmt| try work.append(allocator, stmt.next), - .init_uninitialized => |stmt| try work.append(allocator, stmt.next), - .assign_call => |stmt| { - shape.direct_call_count += 1; - if (stmt.proc == proc_id) shape.self_call_count += 1; - try work.append(allocator, stmt.next); - }, - .assign_call_erased => |stmt| { - shape.erased_call_count += 1; - try work.append(allocator, stmt.next); - }, - .assign_packed_erased_fn => |stmt| try work.append(allocator, stmt.next), - .assign_low_level => |stmt| { - shape.low_level_count += 1; - if (stmt.op == .str_count_utf8_bytes) shape.str_count_utf8_bytes_count += 1; - try work.append(allocator, stmt.next); - }, - .assign_list => |stmt| try work.append(allocator, stmt.next), - .assign_struct => |stmt| { - shape.struct_assign_count += 1; - try work.append(allocator, stmt.next); - }, - .assign_tag => |stmt| { - shape.tag_assign_count += 1; - try work.append(allocator, stmt.next); - }, - .set_local => |stmt| try work.append(allocator, stmt.next), - .debug => |stmt| try work.append(allocator, stmt.next), - .expect => |stmt| try work.append(allocator, stmt.next), - .comptime_branch_taken => |stmt| try work.append(allocator, stmt.next), - .incref => |stmt| try work.append(allocator, stmt.next), - .decref => |stmt| try work.append(allocator, stmt.next), - .decref_if_initialized => |stmt| try work.append(allocator, stmt.next), - .free => |stmt| try work.append(allocator, stmt.next), - .switch_stmt => |stmt| { - shape.switch_count += 1; - if (stmt.continuation) |continuation| try work.append(allocator, continuation); - try work.append(allocator, stmt.default_branch); - const branches = lowered.lir_result.store.getCFSwitchBranches(stmt.branches); - for (0..branches.len) |i| { - const branch = GuardedList.at(branches, i); - try work.append(allocator, branch.body); - } - }, - .switch_initialized_payload => |stmt| { - shape.switch_count += 1; - try work.append(allocator, stmt.initialized_branch); - try work.append(allocator, stmt.uninitialized_branch); - }, - .str_match => |stmt| { - try work.append(allocator, stmt.on_match); - try work.append(allocator, stmt.on_miss); - }, - .str_match_set => |stmt| { - shape.str_match_set_count += 1; - const arms = lowered.lir_result.store.getStrMatchArms(stmt.arms); - for (0..arms.len) |i| { - const arm = GuardedList.at(arms, i); - try work.append(allocator, arm.on_match); - } - try work.append(allocator, stmt.on_miss); - }, - .join => |stmt| { - shape.join_count += 1; - shape.max_join_param_count = @max( - shape.max_join_param_count, - lowered.lir_result.store.getLocalSpan(stmt.params).len, - ); - try work.append(allocator, stmt.body); - try work.append(allocator, stmt.remainder); - }, - .jump => { - shape.jump_count += 1; - }, - .runtime_error, - .comptime_exhaustiveness_failed, - .loop_continue, - .loop_break, - .ret, - .crash, - .expect_err, - => {}, - } - } - - return shape; + return collectLirResultProcShape(allocator, &lowered.lir_result, proc_id); } const IterCollectShape = enum { @@ -1173,6 +1089,7 @@ fn markReachableLiftedExpr( }, .field_access => |field| markReachableLiftedExpr(program, field.receiver, reachable), .tuple_access => |access| markReachableLiftedExpr(program, access.tuple, reachable), + .static_data_candidate => |candidate| markReachableLiftedExpr(program, candidate.runtime_expr, reachable), .structural_eq => |eq| { markReachableLiftedExpr(program, eq.lhs, reachable); markReachableLiftedExpr(program, eq.rhs, reachable); @@ -1359,22 +1276,6 @@ fn hasGroupedStrMatchSet(shape: ProcShape) bool { return shape.str_match_set_count == 1; } -fn expectIterCollectWorkerSpecialized(source: []const u8) TestError!void { - const allocator = std.testing.allocator; - - var optimized = try lowerModule(allocator, source, .wrappers); - defer optimized.deinit(allocator); - - var unoptimized = try lowerModule(allocator, source, .none); - defer unoptimized.deinit(allocator); - - try std.testing.expect(try reachableIterCollectShape(allocator, &optimized.lowered, .specialized)); - try std.testing.expect(!try reachableIterCollectShape(allocator, &optimized.lowered, .generic)); - - try std.testing.expect(!try reachableIterCollectShape(allocator, &unoptimized.lowered, .specialized)); - try std.testing.expect(try reachableIterCollectShape(allocator, &unoptimized.lowered, .generic)); -} - fn rootDirectCallTarget( allocator: Allocator, lowered: *const lir.CheckedPipeline.LoweredProgram, @@ -1988,25 +1889,6 @@ test "trmc: result used before the constructor is not transformed" { , .none); } -test "plant iter pipeline specializes collect worker after inlining" { - try expectIterCollectWorkerSpecialized( - \\Plant : { seed : I64 } - \\ - \\random_plant : I64 -> Plant - \\random_plant = |seed| { seed: seed } - \\ - \\starting_plants : () -> List(Plant) - \\starting_plants = || { - \\ (0.I64..=15) - \\ .map(|i| random_plant(i * 12)) - \\ .collect() - \\} - \\ - \\main : () -> List(Plant) - \\main = || starting_plants() - ); -} - test "known-length List.iter collect specializes without unbound locals" { // Regression: collecting a Known-length iterator (List.iter) under // optimization specializes a recursive capturing worker (List.iter's `make` @@ -2025,21 +1907,6 @@ test "known-length List.iter collect specializes without unbound locals" { defer optimized.deinit(allocator); } -test "direct iter collect worker specializes constructor recursive call" { - try expectIterCollectWorkerSpecialized( - \\Plant : { seed : I64 } - \\ - \\random_plant : I64 -> Plant - \\random_plant = |seed| { seed: seed } - \\ - \\main : () -> List(Plant) - \\main = || - \\ Iter.collect( - \\ Iter.map(0.I64..=15, |i| random_plant(i * 12)), - \\ ) - ); -} - test "spec constr does not duplicate opaque let-bound direct calls" { const allocator = std.testing.allocator; const source = @@ -2528,6 +2395,8 @@ test "LIR statements and procs carry resolved source locations" { .assign_list, .assign_struct, .assign_tag, + .store_struct, + .store_tag, .set_local, .debug, .expect, @@ -2724,3 +2593,2808 @@ test "shared callees are lifted once and never gain spurious captures" { try std.testing.expectEqual(@as(u32, 0), func.captures.len); } } + +const LirProgram = lir.Program; + +const ExpectedHostEvent = union(enum) { + dbg: []const u8, + expect_failed, + crashed: []const u8, +}; + +fn expectOptimizedHostEvents( + source: []const u8, + expected_termination: eval.RuntimeHostEnv.Termination, + expected: []const ExpectedHostEvent, +) TestError!void { + const allocator = std.testing.allocator; + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ .proc_debug_names = true }); + defer optimized.deinit(allocator); + + var run = try runLoweredWithHostEvents(allocator, &optimized.lowered); + defer run.deinit(allocator); + + try std.testing.expectEqual(expected_termination, run.termination); + try std.testing.expectEqual(expected.len, run.events.len); + for (expected, run.events) |expected_event, actual_event| { + switch (expected_event) { + .dbg => |expected_msg| switch (actual_event) { + .dbg => |actual_msg| try std.testing.expectEqualStrings(expected_msg, actual_msg), + else => return error.TestUnexpectedResult, + }, + .expect_failed => switch (actual_event) { + .expect_failed => {}, + else => return error.TestUnexpectedResult, + }, + .crashed => |expected_msg| switch (actual_event) { + .crashed => |actual_msg| try std.testing.expectEqualStrings(expected_msg, actual_msg), + else => return error.TestUnexpectedResult, + }, + } + } +} + +fn collectLirResultProcShape( + allocator: Allocator, + result: *const LirProgram.Result, + proc_id: LIR.LirProcSpecId, +) TestError!ProcShape { + const proc = result.store.getProcSpec(proc_id); + var shape = ProcShape{ + .arg_count = result.store.getLocalSpan(proc.args).len, + }; + + const body = proc.body orelse return shape; + + var work = std.ArrayList(LIR.CFStmtId).empty; + defer work.deinit(allocator); + try work.append(allocator, body); + + var visited = std.AutoHashMap(LIR.CFStmtId, void).init(allocator); + defer visited.deinit(); + + while (work.pop()) |stmt_id| { + const visited_entry = try visited.getOrPut(stmt_id); + if (visited_entry.found_existing) continue; + + switch (result.store.getCFStmt(stmt_id)) { + .assign_ref => |stmt| try work.append(allocator, stmt.next), + .assign_literal => |stmt| try work.append(allocator, stmt.next), + .init_uninitialized => |stmt| try work.append(allocator, stmt.next), + .assign_call => |stmt| { + shape.direct_call_count += 1; + if (stmt.proc == proc_id) shape.self_call_count += 1; + try work.append(allocator, stmt.next); + }, + .assign_call_erased => |stmt| { + shape.erased_call_count += 1; + try work.append(allocator, stmt.next); + }, + .assign_packed_erased_fn => |stmt| { + shape.packed_erased_fn_count += 1; + try work.append(allocator, stmt.next); + }, + .assign_low_level => |stmt| { + shape.low_level_count += 1; + switch (stmt.op) { + .list_len => shape.list_len_count += 1, + .list_get_unsafe => shape.list_get_unsafe_count += 1, + .list_with_capacity => shape.list_with_capacity_count += 1, + .list_append_unsafe => shape.list_append_unsafe_count += 1, + .list_reserve => shape.list_reserve_count += 1, + .str_count_utf8_bytes => shape.str_count_utf8_bytes_count += 1, + .str_concat => shape.str_concat_count += 1, + .box_box => shape.box_box_count += 1, + .box_unbox => shape.box_unbox_count += 1, + .box_prepare_update => shape.box_prepare_update_count += 1, + .ptr_cast => shape.ptr_cast_count += 1, + .ptr_load => shape.ptr_load_count += 1, + .ptr_store => shape.ptr_store_count += 1, + else => {}, + } + try work.append(allocator, stmt.next); + }, + .assign_list => |stmt| try work.append(allocator, stmt.next), + .assign_struct => |stmt| { + shape.struct_assign_count += 1; + try work.append(allocator, stmt.next); + }, + .assign_tag => |stmt| { + shape.tag_assign_count += 1; + try work.append(allocator, stmt.next); + }, + .store_struct => |stmt| { + shape.store_struct_count += 1; + try work.append(allocator, stmt.next); + }, + .store_tag => |stmt| { + shape.store_tag_count += 1; + try work.append(allocator, stmt.next); + }, + .set_local => |stmt| try work.append(allocator, stmt.next), + .debug => |stmt| try work.append(allocator, stmt.next), + .expect => |stmt| try work.append(allocator, stmt.next), + .comptime_branch_taken => |stmt| try work.append(allocator, stmt.next), + .incref => |stmt| try work.append(allocator, stmt.next), + .decref => |stmt| try work.append(allocator, stmt.next), + .decref_if_initialized => |stmt| try work.append(allocator, stmt.next), + .free => |stmt| try work.append(allocator, stmt.next), + .switch_stmt => |stmt| { + shape.switch_count += 1; + if (stmt.continuation) |continuation| try work.append(allocator, continuation); + try work.append(allocator, stmt.default_branch); + const branches = result.store.getCFSwitchBranches(stmt.branches); + for (0..branches.len) |index| { + try work.append(allocator, GuardedList.at(branches, index).body); + } + }, + .switch_initialized_payload => |stmt| { + shape.switch_count += 1; + try work.append(allocator, stmt.initialized_branch); + try work.append(allocator, stmt.uninitialized_branch); + }, + .str_match => |stmt| { + try work.append(allocator, stmt.on_match); + try work.append(allocator, stmt.on_miss); + }, + .str_match_set => |stmt| { + shape.str_match_set_count += 1; + const arms = result.store.getStrMatchArms(stmt.arms); + for (0..arms.len) |index| { + try work.append(allocator, GuardedList.at(arms, index).on_match); + } + try work.append(allocator, stmt.on_miss); + }, + .join => |stmt| { + shape.join_count += 1; + shape.max_join_param_count = @max( + shape.max_join_param_count, + result.store.getLocalSpan(stmt.params).len, + ); + try work.append(allocator, stmt.body); + try work.append(allocator, stmt.remainder); + }, + .jump => { + shape.jump_count += 1; + }, + .runtime_error, + .comptime_exhaustiveness_failed, + .loop_continue, + .loop_break, + .ret, + .crash, + .expect_err, + => {}, + } + } + + return shape; +} + +fn reachableProcDebugName( + allocator: Allocator, + lowered: *const lir.CheckedPipeline.LoweredProgram, + expected_name: []const u8, +) TestError!bool { + var work = std.ArrayList(LIR.LirProcSpecId).empty; + defer work.deinit(allocator); + try work.append(allocator, try rootProc(lowered)); + + var visited = std.AutoHashMap(LIR.LirProcSpecId, void).init(allocator); + defer visited.deinit(); + + while (work.pop()) |proc_id| { + const visited_entry = try visited.getOrPut(proc_id); + if (visited_entry.found_existing) continue; + + if (lowered.lir_result.store.procDebugName(proc_id)) |name| { + if (std.mem.eql(u8, name, expected_name)) return true; + } + + const calls = try collectAssignCallProcs(allocator, lowered, proc_id); + defer allocator.free(calls); + for (calls) |call| try work.append(allocator, call); + } + return false; +} + +fn reachableProcShapeFieldTotal( + allocator: Allocator, + lowered: *const lir.CheckedPipeline.LoweredProgram, + comptime field_name: []const u8, +) TestError!usize { + var work = std.ArrayList(LIR.LirProcSpecId).empty; + defer work.deinit(allocator); + try work.append(allocator, try rootProc(lowered)); + + var visited = std.AutoHashMap(LIR.LirProcSpecId, void).init(allocator); + defer visited.deinit(); + + var total: usize = 0; + while (work.pop()) |proc_id| { + const visited_entry = try visited.getOrPut(proc_id); + if (visited_entry.found_existing) continue; + + const shape = try collectProcShape(allocator, lowered, proc_id); + total += @field(shape, field_name); + + const calls = try collectAssignCallProcs(allocator, lowered, proc_id); + defer allocator.free(calls); + for (calls) |call| try work.append(allocator, call); + } + return total; +} + +fn expectReachableProcShapeFieldNoGreater( + allocator: Allocator, + iter_lowered: *const lir.CheckedPipeline.LoweredProgram, + list_lowered: *const lir.CheckedPipeline.LoweredProgram, + comptime field_name: []const u8, +) TestError!void { + try expectReachableProcShapeFieldNoGreaterBy(allocator, iter_lowered, list_lowered, field_name, 0); +} + +fn expectReachableProcShapeFieldNoGreaterBy( + allocator: Allocator, + iter_lowered: *const lir.CheckedPipeline.LoweredProgram, + list_lowered: *const lir.CheckedPipeline.LoweredProgram, + comptime field_name: []const u8, + allowed_extra: usize, +) TestError!void { + const iter_total = try reachableProcShapeFieldTotal(allocator, iter_lowered, field_name); + const list_total = try reachableProcShapeFieldTotal(allocator, list_lowered, field_name); + try std.testing.expect(iter_total <= list_total + allowed_extra); +} + +fn expectReachableProcShapeFieldEqual( + allocator: Allocator, + lowered: *const lir.CheckedPipeline.LoweredProgram, + comptime field_name: []const u8, + expected: usize, +) TestError!void { + const actual = try reachableProcShapeFieldTotal(allocator, lowered, field_name); + try std.testing.expectEqual(expected, actual); +} + +fn expectStaticListIterAppendLoopAvoidsListAppendAllocation( + iter_source: []const u8, + list_source: []const u8, +) TestError!void { + const allocator = std.testing.allocator; + var iter_optimized = try lowerModuleWithOptions(allocator, iter_source, .wrappers, .{ .tag_reachability = true }); + defer iter_optimized.deinit(allocator); + var list_optimized = try lowerModuleWithOptions(allocator, list_source, .wrappers, .{ .tag_reachability = true }); + defer list_optimized.deinit(allocator); + + try expectReachableProcShapeFieldEqual(allocator, &iter_optimized.lowered, "erased_call_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &iter_optimized.lowered, "packed_erased_fn_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &iter_optimized.lowered, "list_with_capacity_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &iter_optimized.lowered, "list_reserve_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &iter_optimized.lowered, "list_append_unsafe_count", 0); + try expectReachableProcShapeFieldNoGreater(allocator, &iter_optimized.lowered, &list_optimized.lowered, "list_with_capacity_count"); + try expectReachableProcShapeFieldNoGreater(allocator, &iter_optimized.lowered, &list_optimized.lowered, "list_reserve_count"); + try expectReachableProcShapeFieldNoGreater(allocator, &iter_optimized.lowered, &list_optimized.lowered, "list_append_unsafe_count"); + try expectReachableProcShapeFieldNoGreater(allocator, &iter_optimized.lowered, &list_optimized.lowered, "box_box_count"); + try expectReachableProcShapeFieldNoGreaterBy(allocator, &iter_optimized.lowered, &list_optimized.lowered, "switch_count", 1); +} + +fn expectNoReachableErasedCallableLowering( + allocator: Allocator, + lowered: *const lir.CheckedPipeline.LoweredProgram, +) TestError!void { + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, lowered, "erased_call_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, lowered, "packed_erased_fn_count")); +} + +fn expectLoweredIterChainAllocatesNothing( + allocator: Allocator, + lowered: *const lir.CheckedPipeline.LoweredProgram, +) TestError!void { + try expectReachableProcShapeFieldEqual(allocator, lowered, "box_box_count", 0); + try expectReachableProcShapeFieldEqual(allocator, lowered, "erased_call_count", 0); + try expectReachableProcShapeFieldEqual(allocator, lowered, "packed_erased_fn_count", 0); + try expectReachableProcShapeFieldEqual(allocator, lowered, "list_with_capacity_count", 0); +} + +fn expectLoweredIterStateHasNoBoxesOrErasedCallables( + allocator: Allocator, + lowered: *const lir.CheckedPipeline.LoweredProgram, +) TestError!void { + try expectReachableProcShapeFieldEqual(allocator, lowered, "box_box_count", 0); + try expectReachableProcShapeFieldEqual(allocator, lowered, "erased_call_count", 0); + try expectReachableProcShapeFieldEqual(allocator, lowered, "packed_erased_fn_count", 0); +} + +// Zero-allocation gate for iterator chains that escape their construction site +// (returned from a function, passed to a non-inlined function, chosen by a +// branch). Range sources carry no list, so a statically-known chain must lower +// to no heap allocation at all: no boxed iterator state, no erased callable +// dispatch, no list allocation. This is the static companion to the runtime +// allocations_at_most=0 gate in eval_iter_alloc_tests.zig, which cannot express +// module-level function definitions. It checks both `.none` and `.wrappers` so +// the gate proves representation-level minting, not opt-only wrapper +// specialization. RED on the recursive-nominal representation (an escaping +// iterator boxes its state in its constructor). +fn expectEscapingIterChainAllocatesNothing(source: []const u8) TestError!void { + const allocator = std.testing.allocator; + + var ordinary = try lowerModuleWithOptions(allocator, source, .none, .{ .tag_reachability = true }); + defer ordinary.deinit(allocator); + try expectLoweredIterChainAllocatesNothing(allocator, &ordinary.lowered); + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ .tag_reachability = true }); + defer optimized.deinit(allocator); + try expectLoweredIterChainAllocatesNothing(allocator, &optimized.lowered); +} + +test "iter alloc static: iterator returned from a function is zero-alloc" { + try expectEscapingIterChainAllocatesNothing( + \\module [main] + \\ + \\consume : Iter(U64) -> U64 + \\consume = |it| { + \\ var $sum = 0.U64 + \\ for x in it { + \\ $sum = $sum + x + \\ } + \\ $sum + \\} + \\ + \\make : U64 -> Iter(U64) + \\make = |n| Iter.map(Iter.exclusive_range(0.U64, n), |x| x + 1) + \\ + \\main : U64 + \\main = consume(make(5)) + ); +} + +test "iter alloc static: iterator passed to a non-inlined function is zero-alloc" { + try expectEscapingIterChainAllocatesNothing( + \\module [main] + \\ + \\consume : Iter(U64) -> U64 + \\consume = |it| { + \\ var $sum = 0.U64 + \\ for x in it { + \\ $sum = $sum + x + \\ } + \\ $sum + \\} + \\ + \\main : U64 + \\main = consume(Iter.map(Iter.exclusive_range(0.U64, 5), |x| x + 1)) + ); +} + +test "iter alloc static: branch-chosen iterator is zero-alloc" { + try expectEscapingIterChainAllocatesNothing( + \\module [main] + \\ + \\consume : Iter(U64) -> U64 + \\consume = |it| { + \\ var $sum = 0.U64 + \\ for x in it { + \\ $sum = $sum + x + \\ } + \\ $sum + \\} + \\ + \\choose : Bool -> Iter(U64) + \\choose = |flag| + \\ if flag { + \\ Iter.map(Iter.exclusive_range(0.U64, 5), |x| x + 1) + \\ } else { + \\ Iter.keep_if(Iter.exclusive_range(0.U64, 5), |x| x > 2) + \\ } + \\ + \\main : U64 + \\main = consume(choose(5.U64 > 0)) + ); +} + +test "iter alloc static: same adapter with different capture layouts is zero-alloc" { + try expectEscapingIterChainAllocatesNothing( + \\module [main] + \\ + \\Config : { big : U64, small : U64 } + \\ + \\consume : Iter(U64) -> U64 + \\consume = |it| { + \\ var $sum = 0.U64 + \\ for x in it { + \\ $sum = $sum + x + \\ } + \\ $sum + \\} + \\ + \\choose : Bool -> Iter(U64) + \\choose = |flag| { + \\ offset = 1.U64 + \\ config : Config + \\ config = { big: 10, small: 3 } + \\ if flag { + \\ Iter.map(Iter.exclusive_range(0.U64, 5), |x| x + offset) + \\ } else { + \\ Iter.map(Iter.exclusive_range(0.U64, 5), |x| x + config.big + config.small) + \\ } + \\} + \\ + \\main : Bool -> U64 + \\main = |flag| consume(choose(flag)) + ); +} + +test "iter alloc static: runtime-count map wrapping terminates at dynamic boundary" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\consume : Iter(U64) -> U64 + \\consume = |it| { + \\ var $sum = 0.U64 + \\ for x in it { + \\ $sum = $sum + x + \\ } + \\ $sum + \\} + \\ + \\wrap : U64, Iter(U64) -> Iter(U64) + \\wrap = |count, iterator| { + \\ var $i = 0.U64 + \\ var $current = iterator + \\ while $i < count { + \\ offset = $i + \\ $current = Iter.map($current, |x| x + offset) + \\ $i = $i + 1 + \\ } + \\ $current + \\} + \\ + \\main : U64 -> U64 + \\main = |count| consume(wrap(count, Iter.exclusive_range(0.U64, 5))) + ; + + var ordinary = try lowerModuleWithOptions(allocator, source, .none, .{ .tag_reachability = true }); + defer ordinary.deinit(allocator); + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, &ordinary.lowered, "box_box_count") > 0); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "erased_call_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "packed_erased_fn_count", 0); + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ .tag_reachability = true }); + defer optimized.deinit(allocator); + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "box_box_count") > 0); + try expectReachableProcShapeFieldEqual(allocator, &optimized.lowered, "erased_call_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &optimized.lowered, "packed_erased_fn_count", 0); +} + +test "iter alloc static: recursive map wrapping terminates at dynamic boundary" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\consume : Iter(U64) -> U64 + \\consume = |it| { + \\ var $sum = 0.U64 + \\ for x in it { + \\ $sum = $sum + x + \\ } + \\ $sum + \\} + \\ + \\wrap : U64, Iter(U64) -> Iter(U64) + \\wrap = |count, iterator| + \\ if count == 0 { + \\ iterator + \\ } else { + \\ offset = count + \\ wrap(count - 1, Iter.map(iterator, |x| x + offset)) + \\ } + \\ + \\main : U64 -> U64 + \\main = |count| consume(wrap(count, Iter.exclusive_range(0.U64, 5))) + ; + + var ordinary = try lowerModuleWithOptions(allocator, source, .none, .{ .tag_reachability = true }); + defer ordinary.deinit(allocator); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "box_box_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "erased_call_count", 1); + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, &ordinary.lowered, "packed_erased_fn_count") > 0); + + // The `.wrappers` half of this case is blocked on a capture-identity bug + // that the termination fixes above unmasked: the recursively-wrapped + // iterator captures the same binder at two recursion depths, and both + // capture slots receive the same CaptureId, tripping the lift.zig + // "lifted capture set contained two slots with the same CaptureId" + // invariant. Disambiguating recursion-level captures in the + // BinderIdentity/CaptureId system is tracked as its own fix; when it + // lands, this test must also lower the source at `.wrappers` and make + // the same three assertions. +} + +// Both sides of the depth backstop on statically bounded chains: a 10-adapter +// chain (depth 11) stays under the cap and lowers flat, while a 20-adapter +// chain (depth 21) trips it and takes the explicit forced-dynamic callable +// representation. Sources are generated so the two tests differ only in +// adapter count. +fn deepStaticChainSource(comptime map_count: usize) []const u8 { + comptime { + var source: []const u8 = + \\module [main] + \\ + \\main : U64 -> U64 + \\main = |n| { + \\ i0 = Iter.exclusive_range(0.U64, n) + \\ + ; + for (0..map_count) |index| { + source = source ++ std.fmt.comptimePrint(" i{d} = Iter.map(i{d}, |x| x + 1)\n", .{ index + 1, index }); + } + source = source ++ std.fmt.comptimePrint(" Iter.fold(i{d}, 0.U64, |acc, x| acc + x)\n}}\n", .{map_count}); + return source; + } +} + +test "iter alloc static: deep static chain under the depth cap stays flat" { + const allocator = std.testing.allocator; + const source = comptime deepStaticChainSource(10); + var ordinary = try lowerModuleWithOptions(allocator, source, .none, .{ .tag_reachability = true }); + defer ordinary.deinit(allocator); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "box_box_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "erased_call_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "packed_erased_fn_count", 0); +} + +test "iter alloc static: static chain past the depth cap uses forced dynamic representation" { + const allocator = std.testing.allocator; + const source = comptime deepStaticChainSource(20); + var ordinary = try lowerModuleWithOptions(allocator, source, .none, .{ .tag_reachability = true }); + defer ordinary.deinit(allocator); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "box_box_count", 0); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "erased_call_count", 1); + try expectReachableProcShapeFieldEqual(allocator, &ordinary.lowered, "packed_erased_fn_count", 2); +} + +// The base `[list].iter().fold` must lower with no boxed iterator state and no +// erased callable dispatch: the list literal may allocate its backing store, but +// the iterator itself must carry its step closure inline by value. This asserts +// only the iterator-attributable counts (box_box / erased_call / packed_erased); +// the list's own `list_with_capacity` is expected and not asserted here. +test "iter alloc static: base list fold is zero-alloc" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\main : I64 + \\main = { + \\ xs = [1.I64, 2, 3, 4, 5] + \\ Iter.fold(xs.iter(), 0, |a, b| a + b) + \\} + ; + + var ordinary = try lowerModuleWithOptions(allocator, source, .none, .{ .tag_reachability = true }); + defer ordinary.deinit(allocator); + try expectLoweredIterStateHasNoBoxesOrErasedCallables(allocator, &ordinary.lowered); + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ .tag_reachability = true }); + defer optimized.deinit(allocator); + try expectLoweredIterStateHasNoBoxesOrErasedCallables(allocator, &optimized.lowered); +} + +fn reachableReturnSlotProcCount( + allocator: Allocator, + lowered: *const lir.CheckedPipeline.LoweredProgram, +) TestError!usize { + var work = std.ArrayList(LIR.LirProcSpecId).empty; + defer work.deinit(allocator); + try work.append(allocator, try rootProc(lowered)); + + var visited = std.AutoHashMap(LIR.LirProcSpecId, void).init(allocator); + defer visited.deinit(); + + var count: usize = 0; + while (work.pop()) |proc_id| { + const visited_entry = try visited.getOrPut(proc_id); + if (visited_entry.found_existing) continue; + + const proc = lowered.lir_result.store.getProcSpec(proc_id); + const args = lowered.lir_result.store.getLocalSpan(proc.args); + if (proc.ret_layout == .zst and args.len != 0) candidate: { + const first_arg_layout = lowered.lir_result.layouts.getLayout( + lowered.lir_result.store.getLocal(GuardedList.at(args, 0)).layout_idx, + ); + if (first_arg_layout.tag != .ptr) break :candidate; + const result_layout = lowered.lir_result.layouts.getLayout(first_arg_layout.getIdx()); + switch (result_layout.tag) { + .struct_, .tag_union => {}, + else => break :candidate, + } + const shape = try collectProcShape(allocator, lowered, proc_id); + if (shape.ptr_store_count != 0 or shape.store_struct_count != 0 or shape.store_tag_count != 0) count += 1; + } + + const calls = try collectAssignCallProcs(allocator, lowered, proc_id); + defer allocator.free(calls); + for (calls) |call| try work.append(allocator, call); + } + return count; +} + +fn localLoopStateIsSplitToTwoLeaves(shape: ProcShape) bool { + return shape.self_call_count == 0 and + shape.join_count >= 1 and + shape.max_join_param_count == 2 and + shape.jump_count >= 2; +} + +fn whileRecordStateWithCallableCapturesIsSpecialized(shape: ProcShape) bool { + return shape.self_call_count == 0 and + shape.join_count >= 1 and + shape.max_join_param_count == 3 and + shape.jump_count >= 2; +} + +fn whileRecordStateWithZeroCaptureCallableIsSpecialized(shape: ProcShape) bool { + return shape.self_call_count == 0 and + shape.join_count >= 1 and + shape.max_join_param_count == 1 and + shape.jump_count >= 2 and + shape.direct_call_count == 0; +} + +fn whileRecordStateWithOpaqueCallableIsSpecialized(shape: ProcShape) bool { + return shape.self_call_count == 0 and + shape.join_count >= 1 and + shape.max_join_param_count == 2 and + shape.jump_count >= 2; +} + +fn branchJoinedRecordStateWorkerIsSpecialized(shape: ProcShape) bool { + return shape.self_call_count == 0 and + shape.join_count >= 1 and + shape.max_join_param_count == 2 and + shape.jump_count >= 2; +} + +fn branchJoinedRecordStateWorkerIsGeneric(shape: ProcShape) bool { + return shape.self_call_count == 0 and + shape.join_count >= 1 and + shape.max_join_param_count == 1 and + shape.jump_count >= 2; +} + +fn expectRangeMapCollectUsesDirectListLoop(source: []const u8, expected_append_unsafe_count: usize) TestError!void { + const allocator = std.testing.allocator; + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ .proc_debug_names = true }); + defer optimized.deinit(allocator); + + try std.testing.expect(!try reachableIterCollectShape(allocator, &optimized.lowered, .specialized)); + try std.testing.expect(!try reachableIterCollectShape(allocator, &optimized.lowered, .generic)); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "list_len_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "list_get_unsafe_count")); + try std.testing.expectEqual(@as(usize, 1), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "list_with_capacity_count")); + try std.testing.expectEqual(@as(usize, 1), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "list_reserve_count")); + try std.testing.expectEqual(expected_append_unsafe_count, try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "list_append_unsafe_count")); +} + +test "direct call wrapper is inlined under optimized post-check lowering" { + try expectRootDirectCallCount( + \\module [main] + \\ + \\callee : U64 -> U64 + \\callee = |x| x + 1 + \\ + \\wrapper : U64 -> U64 + \\wrapper = |x| callee(x) + \\ + \\main : U64 + \\main = wrapper(41) + , .wrappers, 0); +} + +test "direct call wrapper is not inlined under ordinary post-check lowering" { + try expectRootTargetHasCalls( + \\module [main] + \\ + \\callee : U64 -> U64 + \\callee = |x| x + 1 + \\ + \\wrapper : U64 -> U64 + \\wrapper = |x| callee(x) + \\ + \\main : U64 + \\main = wrapper(41) + , .none); +} + +test "user iter method is not recognized as builtin list cursor" { + const allocator = std.testing.allocator; + var lowered_source = try lowerModule(allocator, + \\module [main] + \\ + \\Bag := [Bag].{ + \\ iter : Bag -> Iter(I64) + \\ iter = |_| Iter.single(1.I64) + \\} + \\ + \\main : I64 + \\main = { + \\ var $sum = 0.I64 + \\ for item in Bag.Bag { + \\ $sum = $sum + item + \\ } + \\ $sum + \\} + , .wrappers); + defer lowered_source.deinit(allocator); + + const shape = try collectProcShape(allocator, &lowered_source.lowered, try rootProc(&lowered_source.lowered)); + try std.testing.expectEqual(@as(usize, 0), shape.list_len_count); + try std.testing.expectEqual(@as(usize, 0), shape.list_get_unsafe_count); +} + +test "destination baseline: boxed record update reboxes a list and string payload" { + const allocator = std.testing.allocator; + var lowered_source = try lowerModule(allocator, + \\module [main] + \\ + \\Plant : { + \\ x : I32, + \\ label : Str, + \\} + \\ + \\Model : { + \\ tick : U64, + \\ label : Str, + \\ plants : List(Plant), + \\} + \\ + \\State : [Running(Model), Done(Str)] + \\ + \\step : Box(State) -> Box(State) + \\step = |boxed| { + \\ state = Box.unbox(boxed) + \\ + \\ next = + \\ match state { + \\ Running(model) => { + \\ plants = List.append(model.plants, { x: 160, label: model.label }) + \\ Running({ ..model, tick: model.tick + 1, plants }) + \\ } + \\ + \\ Done(msg) => Done(Str.concat(msg, "!")) + \\ } + \\ + \\ Box.box(next) + \\} + \\ + \\main : Box(State) -> Box(State) + \\main = |boxed| step(boxed) + , .wrappers); + defer lowered_source.deinit(allocator); + + const step_proc = try rootDirectCallTarget(allocator, &lowered_source.lowered); + const shape = try collectProcShape(allocator, &lowered_source.lowered, step_proc); + + try std.testing.expectEqual(@as(usize, 1), shape.box_unbox_count); + try std.testing.expectEqual(@as(usize, 1), shape.box_box_count); + try std.testing.expect(shape.struct_assign_count >= 2); + try std.testing.expect(shape.tag_assign_count >= 2); +} + +test "destination phase 3: direct boxed update wrapper calls a return-slot variant" { + const allocator = std.testing.allocator; + var lowered_source = try lowerModule(allocator, + \\module [main] + \\ + \\Model : { + \\ tick : U64, + \\ label : Str, + \\} + \\ + \\update : Model -> Model + \\update = |model| { + \\ tick = model.tick + 1 + \\ { ..model, tick } + \\} + \\ + \\step : Box(Model) -> Box(Model) + \\step = |boxed| Box.box(update(Box.unbox(boxed))) + \\ + \\main : Box(Model) -> Box(Model) + \\main = |boxed| step(boxed) + , .wrappers); + defer lowered_source.deinit(allocator); + + const root_shape = try collectProcShape(allocator, &lowered_source.lowered, try rootProc(&lowered_source.lowered)); + + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "box_unbox_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "box_box_count")); + try std.testing.expectEqual(@as(usize, 1), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "box_prepare_update_count")); + try std.testing.expectEqual(@as(usize, 1), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "ptr_cast_count")); + try std.testing.expectEqual(@as(usize, 1), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "ptr_load_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "ptr_store_count")); + try std.testing.expectEqual(@as(usize, 1), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "store_struct_count")); + try std.testing.expectEqual(@as(usize, 0), root_shape.ptr_store_count); + try std.testing.expectEqual(@as(usize, 1), try reachableReturnSlotProcCount(allocator, &lowered_source.lowered)); +} + +test "destination phase 3: effectful boxed update wrapper prepares box update" { + const allocator = std.testing.allocator; + var lowered_source = try lowerModule(allocator, + \\module [main] + \\ + \\Model : { + \\ tick : U64, + \\ label : Str, + \\} + \\ + \\update! : Model => Model + \\update! = |model| { + \\ tick = model.tick + 1 + \\ { ..model, tick } + \\} + \\ + \\main : Box(Model) => Box(Model) + \\main = |boxed| Box.box(update!(Box.unbox(boxed))) + , .wrappers); + defer lowered_source.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "box_unbox_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "box_box_count")); + try std.testing.expectEqual(@as(usize, 1), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "box_prepare_update_count")); +} + +test "destination baseline: boxed lambda is packed then boxed" { + const allocator = std.testing.allocator; + var lowered_source = try lowerModule(allocator, + \\module [main] + \\ + \\Formatter : U64 -> Str + \\ + \\make : Str -> Box(Formatter) + \\make = |prefix| Box.box(|n| Str.concat(prefix, U64.to_str(n))) + \\ + \\main : Str -> Box(Formatter) + \\main = |prefix| make(prefix) + , .none); + defer lowered_source.deinit(allocator); + + const make_proc = try rootDirectCallTarget(allocator, &lowered_source.lowered); + const shape = try collectProcShape(allocator, &lowered_source.lowered, make_proc); + + try std.testing.expectEqual(@as(usize, 1), shape.packed_erased_fn_count); +} + +test "destination baseline: large record return feeds a record update" { + const allocator = std.testing.allocator; + var lowered_source = try lowerModule(allocator, + \\module [main] + \\ + \\Big : { + \\ label : Str, + \\ items : List(U64), + \\ a : U64, + \\ b : U64, + \\ c : U64, + \\ d : U64, + \\ e : U64, + \\} + \\ + \\make_big : Str, U64 -> Big + \\make_big = |label, n| { + \\ label, + \\ items: [n, n + 1], + \\ a: n, + \\ b: n + 1, + \\ c: n + 2, + \\ d: n + 3, + \\ e: n + 4, + \\} + \\ + \\change_big : Str, U64 -> Big + \\change_big = |label, n| { ..make_big(label, n), e: n + 5 } + \\ + \\main : Str, U64 -> Big + \\main = |label, n| change_big(label, n) + , .none); + defer lowered_source.deinit(allocator); + + const change_proc = try rootDirectCallTarget(allocator, &lowered_source.lowered); + const shape = try collectProcShape(allocator, &lowered_source.lowered, change_proc); + + try std.testing.expect(shape.direct_call_count >= 1); + try std.testing.expect(shape.struct_assign_count >= 1); +} + +test "destination phase 6: string concat caller uses append variant" { + const allocator = std.testing.allocator; + var lowered_source = try lowerModule(allocator, + \\module [main] + \\ + \\suffix : Str -> Str + \\suffix = |input| { + \\ middle = if input == "" { input } else { input } + \\ Str.concat(middle, "!") + \\} + \\ + \\build : Str -> Str + \\build = |input| { + \\ prefix = if input == "" { "pre" } else { "pre" } + \\ result = suffix(input) + \\ Str.concat(prefix, result) + \\} + \\ + \\main : Str -> Str + \\main = |input| { + \\ held = input + \\ build(held) + \\} + , .wrappers); + defer lowered_source.deinit(allocator); + + const build_proc = try rootDirectCallTarget(allocator, &lowered_source.lowered); + const build_shape = try collectProcShape(allocator, &lowered_source.lowered, build_proc); + + try std.testing.expectEqual(@as(usize, 1), build_shape.direct_call_count); + try std.testing.expectEqual(@as(usize, 0), build_shape.str_concat_count); + try std.testing.expectEqual(@as(usize, 2), try reachableProcShapeFieldTotal(allocator, &lowered_source.lowered, "str_concat_count")); +} + +// Ported pending iterator redesign: the materialize-inline plan decision this test asserts is not part of the current inline plan. +// test "call value wrapper is optimized-inline eligible but not materialize-inline eligible" { +// try expectInlinePlanDecisions( +// \\module [main] +// \\ +// \\callee : U64 -> U64 +// \\callee = |x| x + 1 +// \\ +// \\apply : (U64 -> U64), U64 -> U64 +// \\apply = |fn, x| fn(x) +// \\ +// \\main : U64 +// \\main = apply(callee, 41) +// , "apply", true, false); +// } + +// Ported pending iterator redesign: the materialize-inline plan decision this test asserts is not part of the current inline plan. +// test "simple direct low-level wrapper is materialize-inline eligible" { +// try expectInlinePlanDecisions( +// \\module [main] +// \\ +// \\callee : U64 -> U64 +// \\callee = |x| x + 1 +// \\ +// \\main : U64 -> U64 +// \\main = |x| callee(x) +// , "callee", true, true); +// } + +test "capturing direct wrapper is inlined when captures are inline inputs" { + const allocator = std.testing.allocator; + var lowered_source = try lowerModule(allocator, + \\module [main] + \\ + \\callee : U64 -> U64 + \\callee = |x| x + 1 + \\ + \\main : U64 -> U64 + \\main = |offset| { + \\ wrapper = |x| callee(x + offset) + \\ wrapper(41) + \\} + , .wrappers); + defer lowered_source.deinit(allocator); + + const root_calls = try collectAssignCallProcs(allocator, &lowered_source.lowered, try rootProc(&lowered_source.lowered)); + defer allocator.free(root_calls); + + try std.testing.expectEqual(@as(usize, 0), root_calls.len); + const root_shape = try collectProcShape(allocator, &lowered_source.lowered, try rootProc(&lowered_source.lowered)); + try std.testing.expectEqual(@as(usize, 0), root_shape.direct_call_count); +} +// ─── TRMC pass outcomes through the full pipeline ─── + +test "plant iter pipeline collect uses direct range map list loop" { + try expectRangeMapCollectUsesDirectListLoop( + \\module [main] + \\ + \\Plant : { seed : I64 } + \\ + \\random_plant : I64 -> Plant + \\random_plant = |seed| { seed: seed } + \\ + \\starting_plants : () -> List(Plant) + \\starting_plants = || { + \\ (0.I64..=15) + \\ .map(|i| random_plant(i * 12)) + \\ .collect() + \\} + \\ + \\main : () -> List(Plant) + \\main = || starting_plants() + , 2); +} + +test "direct range map collect uses direct list loop" { + try expectRangeMapCollectUsesDirectListLoop( + \\module [main] + \\ + \\Plant : { seed : I64 } + \\ + \\random_plant : I64 -> Plant + \\random_plant = |seed| { seed: seed } + \\ + \\main : () -> List(Plant) + \\main = || + \\ Iter.collect( + \\ Iter.map(0.I64..=15, |i| random_plant(i * 12)), + \\ ) + , 2); +} + +test "non-inlined call list argument keeps let-bound leaves available" { + // A boundary call cannot be inlined, so its arguments must materialize as + // ordinary public values. A list argument whose elements are let-bound + // locals must keep those bindings available (or substituted) when the + // boundary materializes inside nested inlining. + const allocator = std.testing.allocator; + var optimized = try lowerModule(allocator, + \\module [main] + \\ + \\len_rec : List(U64), U64 -> U64 + \\len_rec = |bytes, acc| { + \\ match bytes { + \\ [] => acc + \\ [_, .. as rest] => len_rec(rest, acc + 1) + \\ } + \\} + \\ + \\countdown : U64 -> U64 + \\countdown = |x| if x == 0 1 else countdown(x - 1) + \\ + \\save : U64 -> U64 + \\save = |frame| { + \\ data = U64.bitwise_and(frame, 255) + \\ other = countdown(3) + \\ len_rec([data, other], 0) + \\} + \\ + \\init : { frame : U64 } -> U64 + \\init = |state| { + \\ frame_count = state.frame + \\ save(frame_count) + \\} + \\ + \\step : { frame : U64 }, U64 -> U64 + \\step = |state, mode| { + \\ if mode == 1 { + \\ init(state) + \\ } else { + \\ 0 + \\ } + \\} + \\ + \\main : U64 + \\main = step({ frame: 9 }, 1) + , .wrappers); + defer optimized.deinit(allocator); +} + +test "multi-use match binding emits branch bodies once" { + // A control-flow value re-emits its branch bodies wherever it + // materializes, so a let-bound match consumed by more than one + // materializing use must be emitted once at its binding statement and + // referenced; otherwise every use duplicates every branch body. + const allocator = std.testing.allocator; + var optimized = try lowerModule(allocator, + \\module [main] + \\ + \\route : U64 -> U64 + \\route = |x| { + \\ if x > 3 { + \\ return 0 + \\ } + \\ x + 1 + \\} + \\ + \\label : U64 -> Str + \\label = |n| { + \\ state = match route(n) { + \\ 0 => Str.concat("a", "0") + \\ 1 => Str.concat("b", "1") + \\ 2 => Str.concat("c", "2") + \\ _ => Str.concat("d", "?") + \\ } + \\ Str.concat(state, state) + \\} + \\ + \\main : Str + \\main = label(9) + , .wrappers); + defer optimized.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 5), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "str_concat_count")); +} + +test "boundary field access projects private leaf branch" { + // A record consumed only through demanded field accesses splits into a + // sparse private product, and an if branch whose value is an opaque call + // result is carried whole as a private leaf. A boundary argument that + // projects a field from such an if value must project through every + // branch — including the leaf branch, whose field is an ordinary field + // access on the carried public value — rather than materialize the + // sparse receiver whole. + const allocator = std.testing.allocator; + var optimized = try lowerModule(allocator, + \\module [main] + \\ + \\countdown : U64 -> U64 + \\countdown = |x| { + \\ if x > 3 { + \\ return 0 + \\ } + \\ x + 1 + \\} + \\ + \\load : U64 -> { score : U64, hi : U64, pad : U64 } + \\load = |seed| { + \\ if seed == 0 { + \\ { score: 0, hi: 1, pad: 2 } + \\ } else { + \\ load(seed - 1) + \\ } + \\} + \\ + \\use : { score : U64, hi : U64, pad : U64 }, U64 -> U64 + \\use = |state, mode| { + \\ match countdown(state.score) { + \\ 1 => state.hi + mode + \\ other => other + \\ } + \\} + \\ + \\main : U64 + \\main = { + \\ state = if countdown(3) == 1 { + \\ { score: 10, hi: 20, pad: 30 } + \\ } else { + \\ load(7) + \\ } + \\ use(state, 1) + \\} + , .wrappers); + defer optimized.deinit(allocator); +} + +test "local iterator append loop demands step captures across states" { + // The append step callable's appended-item capture is demanded only + // through the step-result `item` demand observed inside the loop body. + // That observation must reach the owning loop demand node so the state + // key carries the capture; otherwise the state callable is reconstructed + // without a capture its body demands. + const allocator = std.testing.allocator; + var optimized = try lowerModule(allocator, + \\module [main] + \\ + \\Point : { x : I64 } + \\ + \\points : () -> Iter(Point) + \\points = || [{ x: 1.I64 }, { x: 2 }].iter().append({ x: 3 }) + \\ + \\main : I64 + \\main = { + \\ iter = points() + \\ var $sum = 0.I64 + \\ for point in iter { + \\ $sum = $sum + point.x + \\ } + \\ $sum + \\} + , .wrappers); + defer optimized.deinit(allocator); + + try expectNoReachableErasedCallableLowering(allocator, &optimized.lowered); +} + +test "imported iterator producer keeps finite step callables" { + const allocator = std.testing.allocator; + const producer_module = + \\module [points] + \\ + \\Point : { x : I64 } + \\ + \\points : () -> Iter(Point) + \\points = || [{ x: 1.I64 }, { x: 2 }].iter().append({ x: 3 }) + ; + const source = + \\module [main] + \\ + \\import Points + \\ + \\main : I64 + \\main = { + \\ iter = Points.points() + \\ var $sum = 0.I64 + \\ for point in iter { + \\ $sum = $sum + point.x + \\ } + \\ $sum + \\} + ; + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ + .imports = &.{.{ .name = "Points", .source = producer_module }}, + }); + defer optimized.deinit(allocator); + + try expectNoReachableErasedCallableLowering(allocator, &optimized.lowered); +} + +test "static list iter append loop eliminates public iter adapters" { + const allocator = std.testing.allocator; + const iter_source = + \\module [main] + \\ + \\Point : { x : I64, y : I64 } + \\ + \\sum_points : U64 -> I64 + \\sum_points = |anim_index| { + \\ base_points = [ + \\ { x: 11, y: 2 }, + \\ { x: 13, y: 3 }, + \\ { x: 3, y: 5 }, + \\ { x: 11, y: 6 }, + \\ ].iter() + \\ + \\ collision_points = + \\ if anim_index == 2 { + \\ base_points.append({ x: 2, y: 1 }).append({ x: 7, y: 1 }) + \\ } else if anim_index == 1 { + \\ base_points.append({ x: 2, y: 2 }) + \\ } else { + \\ base_points + \\ } + \\ + \\ var $sum = 0 + \\ for { x, y } in collision_points { + \\ $sum = $sum + x + y + \\ } + \\ $sum + \\} + \\ + \\main : I64 + \\main = sum_points(2) + ; + const list_source = + \\module [main] + \\ + \\Point : { x : I64, y : I64 } + \\ + \\sum_points : U64 -> I64 + \\sum_points = |anim_index| { + \\ base_points = [ + \\ { x: 11, y: 2 }, + \\ { x: 13, y: 3 }, + \\ { x: 3, y: 5 }, + \\ { x: 11, y: 6 }, + \\ ] + \\ + \\ collision_points = + \\ if anim_index == 2 { + \\ base_points.append({ x: 2, y: 1 }).append({ x: 7, y: 1 }) + \\ } else if anim_index == 1 { + \\ base_points.append({ x: 2, y: 2 }) + \\ } else { + \\ base_points + \\ } + \\ + \\ var $sum = 0 + \\ for { x, y } in collision_points { + \\ $sum = $sum + x + y + \\ } + \\ $sum + \\} + \\ + \\main : I64 + \\main = sum_points(2) + ; + + var iter_optimized = try lowerModuleWithProcDebugNames(allocator, iter_source, .wrappers, true); + defer iter_optimized.deinit(allocator); + var list_optimized = try lowerModuleWithProcDebugNames(allocator, list_source, .wrappers, true); + defer list_optimized.deinit(allocator); + + try std.testing.expect(!try reachableProcDebugName(allocator, &iter_optimized.lowered, "Builtin.List.iter")); + try std.testing.expect(!try reachableProcDebugName(allocator, &iter_optimized.lowered, "Builtin.Iter.append")); + try std.testing.expect(!try reachableProcDebugName(allocator, &iter_optimized.lowered, "iter_from_step")); + try std.testing.expect(!try reachableProcDebugName(allocator, &list_optimized.lowered, "Builtin.Iter.append")); +} + +// Ported pending iterator redesign: post_check_stats.optimized_contexts instrumentation is not part of the current pipeline. +// test "post-check lowering mode constructs optimized context only in optimized mode" { +// const allocator = std.testing.allocator; +// const source = +// \\module [main] +// \\ +// \\main : U64 +// \\main = 0 +// ; +// +// var optimized = try lowerModule(allocator, source, .wrappers); +// defer optimized.deinit(allocator); +// var ordinary = try lowerModule(allocator, source, .none); +// defer ordinary.deinit(allocator); +// +// try std.testing.expectEqual(@as(u32, 1), optimized.lowered.post_check_stats.optimized_contexts); +// try std.testing.expectEqual(@as(u32, 0), ordinary.lowered.post_check_stats.optimized_contexts); +// } + +// Ported pending iterator redesign: post_check_stats.optimized_contexts instrumentation is not part of the current pipeline. +// test "checking finalization lowering constructs no optimized context" { +// const allocator = std.testing.allocator; +// const source = +// \\module [main] +// \\ +// \\main : U64 +// \\main = 0 +// ; +// +// var lowered = try lowerModuleWithOptions(allocator, source, .none, .{ +// .checked_module_state = .checking_finalization, +// }); +// defer lowered.deinit(allocator); +// +// try std.testing.expectEqual(@as(u32, 0), lowered.lowered.post_check_stats.optimized_contexts); +// } + +test "post-check lowering mode gates public iter adapter elimination" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\sum_points : U64 -> U64 + \\sum_points = |extra| { + \\ base_points = [1, 2, 3].iter() + \\ + \\ collision_points = + \\ if extra == 0 { + \\ base_points + \\ } else { + \\ base_points.append(extra) + \\ } + \\ + \\ var $sum = 0 + \\ for point in collision_points { + \\ $sum = $sum + point + \\ } + \\ $sum + \\} + \\ + \\main : U64 + \\main = sum_points(4) + ; + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ .proc_debug_names = true }); + defer optimized.deinit(allocator); + var ordinary = try lowerModuleWithOptions(allocator, source, .none, .{ .proc_debug_names = true }); + defer ordinary.deinit(allocator); + + try std.testing.expect(!try reachableProcDebugName(allocator, &optimized.lowered, "Builtin.Iter.append")); + try std.testing.expect(try reachableProcDebugName(allocator, &ordinary.lowered, "Builtin.Iter.append")); +} + +// Ported pending iterator redesign: this test constructs state_loop/state_continue lifted IR that the current lifted AST does not define. +// test "state loop lowers to ordinary lir joins" { +// const allocator = std.testing.allocator; +// const source = +// \\module [main] +// \\ +// \\main : U64 +// \\main = 0 +// ; +// +// var lifted_source = try liftModuleAfterSpecConstr(allocator, source); +// defer helpers.cleanupParseAndCanonical(allocator, lifted_source.resources); +// +// const Lifted = postcheck.MonotypeLifted.Ast; +// var lifted = lifted_source.lifted; +// var lifted_owned = true; +// defer if (lifted_owned) lifted.deinit(); +// lifted_source.lifted = undefined; +// +// try std.testing.expectEqual(@as(usize, 1), lifted.roots.items.len); +// const root_fn_id = lifted.roots.items[0].fn_id; +// const root_fn_index = @intFromEnum(root_fn_id); +// const ret_ty = lifted.fns.items[root_fn_index].ret; +// const original_body = switch (lifted.fns.items[root_fn_index].body) { +// .roc => |body| body, +// .hosted => return error.TestUnexpectedResult, +// }; +// +// const empty_params = try lifted.addTypedLocalSpan(&.{}); +// const empty_values = try lifted.addExprSpan(&.{}); +// const state_start: u32 = @intCast(lifted.state_loop_states.items.len); +// const state0_id: Lifted.StateLoopStateId = @enumFromInt(state_start); +// const state1_id: Lifted.StateLoopStateId = @enumFromInt(state_start + 1); +// +// const break_expr = try lifted.addExpr(.{ +// .ty = ret_ty, +// .data = .{ .break_ = original_body }, +// }); +// const continue_expr = try lifted.addExpr(.{ +// .ty = ret_ty, +// .data = .{ .state_continue = .{ +// .target_state = state1_id, +// .values = empty_values, +// } }, +// }); +// const states = [_]Lifted.StateLoopState{ +// .{ +// .params = empty_params, +// .body = continue_expr, +// }, +// .{ +// .params = empty_params, +// .body = break_expr, +// }, +// }; +// const state_span = try lifted.addStateLoopStateSpan(&states); +// const state_loop_expr = try lifted.addExpr(.{ +// .ty = ret_ty, +// .data = .{ .state_loop = .{ +// .entry_state = state0_id, +// .entry_values = empty_values, +// .states = state_span, +// } }, +// }); +// lifted.fns.items[root_fn_index].body = .{ .roc = state_loop_expr }; +// +// var solved = try postcheck.LambdaSolved.Solve.run(allocator, lifted); +// lifted_owned = false; +// lifted = undefined; +// var solved_owned = true; +// errdefer if (solved_owned) solved.deinit(); +// +// var output = try postcheck.SolvedLirLower.run(allocator, base.target.TargetUsize.native, solved, .{}); +// solved_owned = false; +// solved = undefined; +// defer output.deinit(); +// +// try std.testing.expectEqual(@as(usize, 1), output.lir_result.root_procs.items.len); +// const root_proc = output.lir_result.root_procs.items[0]; +// const shape = try collectLirResultProcShape(allocator, &output.lir_result, root_proc); +// +// try std.testing.expectEqual(@as(usize, 2), shape.join_count); +// try std.testing.expectEqual(@as(usize, 0), shape.max_join_param_count); +// try std.testing.expectEqual(@as(usize, 2), shape.jump_count); +// } + +test "dynamic static list iter append loop splits nested callable captures" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\Point : { x : I64, y : I64 } + \\ + \\main : U64 -> I64 + \\main = |anim_index| { + \\ base_points = [ + \\ { x: 11, y: 2 }, + \\ { x: 13, y: 3 }, + \\ { x: 3, y: 5 }, + \\ { x: 11, y: 6 }, + \\ ].iter() + \\ + \\ collision_points = + \\ if anim_index == 2 { + \\ base_points.append({ x: 2, y: 1 }).append({ x: 7, y: 1 }) + \\ } else if anim_index == 1 { + \\ base_points.append({ x: 2, y: 2 }) + \\ } else { + \\ base_points + \\ } + \\ + \\ var $sum = 0 + \\ for { x, y } in collision_points { + \\ $sum = $sum + x + y + \\ } + \\ $sum + \\} + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); +} + +test "static record list iter append loop avoids direct-list append allocation" { + const record_iter_source = + \\module [main] + \\ + \\Point : { x : I64, y : I64 } + \\ + \\main : Bool -> I64 + \\main = |use_extra| { + \\ base_points = [ + \\ { x: 11, y: 2 }, + \\ ].iter() + \\ + \\ collision_points = + \\ if use_extra { + \\ base_points.append({ x: 2, y: 1 }) + \\ } else { + \\ base_points + \\ } + \\ + \\ var $sum = 0 + \\ for { x, y } in collision_points { + \\ $sum = $sum + x + y + \\ } + \\ $sum + \\} + ; + const record_list_source = + \\module [main] + \\ + \\Point : { x : I64, y : I64 } + \\ + \\main : Bool -> I64 + \\main = |use_extra| { + \\ base_points = [ + \\ { x: 11, y: 2 }, + \\ ] + \\ + \\ collision_points = + \\ if use_extra { + \\ base_points.append({ x: 2, y: 1 }) + \\ } else { + \\ base_points + \\ } + \\ + \\ var $sum = 0 + \\ for { x, y } in collision_points { + \\ $sum = $sum + x + y + \\ } + \\ $sum + \\} + ; + + try expectStaticListIterAppendLoopAvoidsListAppendAllocation(record_iter_source, record_list_source); +} + +test "static primitive list iter append loop avoids direct-list append allocation" { + const primitive_iter_source = + \\module [main] + \\ + \\main : Bool -> I64 + \\main = |use_extra| { + \\ base_points = [11.I64].iter() + \\ + \\ collision_points = + \\ if use_extra { + \\ base_points.append(2) + \\ } else { + \\ base_points + \\ } + \\ + \\ var $sum = 0 + \\ for point in collision_points { + \\ $sum = $sum + point + \\ } + \\ $sum + \\} + ; + const primitive_list_source = + \\module [main] + \\ + \\main : Bool -> I64 + \\main = |use_extra| { + \\ base_points = [11.I64] + \\ + \\ collision_points = + \\ if use_extra { + \\ base_points.append(2) + \\ } else { + \\ base_points + \\ } + \\ + \\ var $sum = 0 + \\ for point in collision_points { + \\ $sum = $sum + point + \\ } + \\ $sum + \\} + ; + + try expectStaticListIterAppendLoopAvoidsListAppendAllocation(primitive_iter_source, primitive_list_source); +} + +test "stream from iterator collect keeps finite step callables" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\main : () => List(I64) + \\main = || { + \\ stream = + \\ [1.I64, 2] + \\ .iter() + \\ .append(3) + \\ .stream() + \\ .map!(|n| n + 1) + \\ + \\ Stream.collect!(stream) + \\} + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + try expectNoReachableErasedCallableLowering(allocator, &optimized.lowered); +} + +test "optimized infinite custom iterator consumes finite prefix" { + const source = + \\module [main] + \\ + \\main : U64 + \\main = { + \\ adv : ((U64, U64) -> Try((U64, (U64, U64)), [NoMore])) + \\ adv = |(a, b)| Try.Ok((a, (b, a + b))) + \\ + \\ fib_iter = Iter.custom((0.U64, 1.U64), Unknown, adv) + \\ + \\ var $sum = 0.U64 + \\ for f in fib_iter.take_first(5) { + \\ $sum = $sum + f + \\ } + \\ dbg $sum + \\ $sum + \\} + ; + + try expectOptimizedDbgEvents(source, &.{"7"}); +} + +test "spec constr list filter-map loop does not produce unbound ARC locals" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\main : List(I32) + \\main = { + \\ var $out = [] + \\ for item in [] { + \\ $out = $out.append(item) + \\ } + \\ $out + \\} + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); +} + +test "spec constr preserves known-match expect failure order" { + try expectOptimizedHostEvents( + \\module [main] + \\ + \\State : { n : I64 } + \\Step : [One({ item : I64 })] + \\ + \\tap : I64 -> I64 + \\tap = |n| { + \\ dbg "payload" + \\ n + \\} + \\ + \\outer : State -> I64 + \\outer = |state| + \\ match One({ item: tap(state.n) }) { + \\ One({ item }) => { + \\ dbg "branch-before" + \\ expect False + \\ item + \\ } + \\ } + \\ + \\main : I64 + \\main = outer({ n: 1 }) + , .returned, &.{ + .{ .dbg = "\"payload\"" }, + .{ .dbg = "\"branch-before\"" }, + .expect_failed, + }); +} + +test "spec constr preserves known-match crash order" { + try expectOptimizedHostEvents( + \\module [main] + \\ + \\State : { n : I64 } + \\Step : [One({ item : I64 })] + \\ + \\tap : I64 -> I64 + \\tap = |n| { + \\ dbg "payload" + \\ n + \\} + \\ + \\outer : State -> I64 + \\outer = |state| + \\ match One({ item: tap(state.n) }) { + \\ One({ item: _ }) => { + \\ dbg "branch-before" + \\ crash "boom" + \\ } + \\ } + \\ + \\main : I64 + \\main = outer({ n: 1 }) + , .crashed, &.{ + .{ .dbg = "\"payload\"" }, + .{ .dbg = "\"branch-before\"" }, + .{ .crashed = "boom" }, + }); +} + +test "spec constr specializes primitive-start record state carried by while loop" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\State : { n : I64, acc : I64 } + \\ + \\sum_from : I64 -> I64 + \\sum_from = |start| { + \\ var $state = { n: start, acc: 0 } + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, acc: $state.acc + $state.n } + \\ } + \\ + \\ $state.acc + \\} + \\ + \\main : I64 + \\main = sum_from(4) + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWorkerIsSpecialized)); + try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWorkerIsGeneric)); + + try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsSpecialized)); + try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsGeneric)); +} + +test "spec constr does not require single-field record wrapper for local loop splitting" { + const allocator = std.testing.allocator; + const wrapped_source = + \\module [main] + \\ + \\Start : { n : I64 } + \\State : { n : I64, acc : I64 } + \\ + \\sum_from : Start -> I64 + \\sum_from = |start| { + \\ var $state = { n: start.n, acc: 0 } + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, acc: $state.acc + $state.n } + \\ } + \\ + \\ $state.acc + \\} + \\ + \\main : I64 + \\main = sum_from({ n: 4 }) + ; + const primitive_source = + \\module [main] + \\ + \\State : { n : I64, acc : I64 } + \\ + \\sum_from : I64 -> I64 + \\sum_from = |start| { + \\ var $state = { n: start, acc: 0 } + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, acc: $state.acc + $state.n } + \\ } + \\ + \\ $state.acc + \\} + \\ + \\main : I64 + \\main = sum_from(4) + ; + + var wrapped_optimized = try lowerModule(allocator, wrapped_source, .wrappers); + defer wrapped_optimized.deinit(allocator); + var primitive_optimized = try lowerModule(allocator, primitive_source, .wrappers); + defer primitive_optimized.deinit(allocator); + + try std.testing.expect(try reachableProcShape(allocator, &wrapped_optimized.lowered, localLoopStateIsSplitToTwoLeaves)); + try std.testing.expect(try reachableProcShape(allocator, &primitive_optimized.lowered, localLoopStateIsSplitToTwoLeaves)); +} + +test "spec constr splits loop record state with opaque callable field" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\State : { n : I64, f : I64 -> I64 } + \\ + \\inc : I64 -> I64 + \\inc = |n| n + 1 + \\ + \\sum_from : I64 -> I64 + \\sum_from = |start| { + \\ var $state = { n: start, f: inc } + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, f: $state.f } + \\ } + \\ + \\ f = $state.f + \\ f($state.n) + \\} + \\ + \\main : I64 + \\main = sum_from(4) + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWithZeroCaptureCallableIsSpecialized)); + + try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsSpecialized)); + try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsGeneric)); +} + +test "spec constr splits loop record state with direct callable captures" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\State : { n : I64, f : I64 -> I64 } + \\ + \\sum_from : I64, I64, I64 -> I64 + \\sum_from = |start, scale, offset| { + \\ f = |n| n * scale + offset + \\ var $state = { n: start, f } + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, f: $state.f } + \\ } + \\ + \\ f = $state.f + \\ f($state.n) + \\} + \\ + \\main : I64 + \\main = sum_from(4, 10, 3) + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWithCallableCapturesIsSpecialized)); + try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWithOpaqueCallableIsSpecialized)); + + try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWithCallableCapturesIsSpecialized)); + try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsGeneric)); +} + +test "spec constr splits loop record state with returned callable captures" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\State : { n : I64, f : I64 -> I64 } + \\ + \\make_affine = |scale, offset| |n| n * scale + offset + \\ + \\sum_from : I64, I64, I64 -> I64 + \\sum_from = |start, scale, offset| { + \\ var $state = { n: start, f: make_affine(scale, offset) } + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, f: $state.f } + \\ } + \\ + \\ f = $state.f + \\ f($state.n) + \\} + \\ + \\main : I64 + \\main = sum_from(4, 10, 3) + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWithCallableCapturesIsSpecialized)); + try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWithOpaqueCallableIsSpecialized)); + + try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWithCallableCapturesIsSpecialized)); + try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsGeneric)); +} + +test "spec constr splits loop record state with annotated returned callable captures" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\State : { n : I64, f : I64 -> I64 } + \\ + \\make_affine : I64, I64 -> (I64 -> I64) + \\make_affine = |scale, offset| |n| n * scale + offset + \\ + \\sum_from : I64, I64, I64 -> I64 + \\sum_from = |start, scale, offset| { + \\ var $state = { n: start, f: make_affine(scale, offset) } + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, f: $state.f } + \\ } + \\ + \\ f = $state.f + \\ f($state.n) + \\} + \\ + \\main : I64 + \\main = sum_from(4, 10, 3) + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWithCallableCapturesIsSpecialized)); + try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWithOpaqueCallableIsSpecialized)); + + try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWithCallableCapturesIsSpecialized)); + try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsGeneric)); +} + +test "spec constr exposes direct call record result for field access" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\Start : { n : I64 } + \\State : { n : I64, acc : I64 } + \\ + \\make_state : I64 -> State + \\make_state = |n| { n: n, acc: n + 1 } + \\ + \\read_acc : Start -> I64 + \\read_acc = |start| make_state(start.n).acc + \\ + \\main : I64 + \\main = read_acc({ n: 4 }) + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "direct_call_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "struct_assign_count")); + + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, &unoptimized.lowered, "direct_call_count") > 0); + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, &unoptimized.lowered, "struct_assign_count") > 0); +} + +test "spec constr exposes block-wrapped direct call record result for field access" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\State : { n : I64, acc : I64 } + \\ + \\make_state : I64 -> State + \\make_state = |n| { n: n, acc: n + 1 } + \\ + \\main : I64 + \\main = { make_state(4) }.acc + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "direct_call_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "struct_assign_count")); + + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, &unoptimized.lowered, "direct_call_count") > 0); + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, &unoptimized.lowered, "struct_assign_count") > 0); +} + +test "spec constr exposes demanded direct call argument facts" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\State : { n : I64, acc : I64 } + \\ + \\make_state : I64 -> State + \\make_state = |n| { n: n, acc: n + 1 } + \\ + \\copy_state : State -> State + \\copy_state = |state| { n: state.n, acc: state.acc } + \\ + \\main : I64 + \\main = copy_state(make_state(4)).acc + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "direct_call_count")); + + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, &unoptimized.lowered, "direct_call_count") > 0); +} + +test "spec constr specializes if-joined record state carried by while loop" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\Start : { n : I64 } + \\State : { n : I64, acc : I64 } + \\ + \\sum_from : Start, Bool -> I64 + \\sum_from = |seed, flag| { + \\ start = + \\ if flag { + \\ { n: seed.n, acc: 0 } + \\ } else { + \\ { n: seed.n - 1, acc: 1 } + \\ } + \\ + \\ var $state = start + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, acc: $state.acc + $state.n } + \\ } + \\ + \\ $state.acc + \\} + \\ + \\main : I64 + \\main = sum_from({ n: 4 }, True) + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, branchJoinedRecordStateWorkerIsSpecialized)); + try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, branchJoinedRecordStateWorkerIsGeneric)); + + try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, branchJoinedRecordStateWorkerIsSpecialized)); + try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, branchJoinedRecordStateWorkerIsGeneric)); +} + +test "spec constr specializes match-joined record state carried by while loop" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\Start : { n : I64 } + \\State : { n : I64, acc : I64 } + \\ + \\sum_from : Start, Bool -> I64 + \\sum_from = |seed, flag| { + \\ start = + \\ match flag { + \\ True => { n: seed.n, acc: 0 } + \\ False => { n: seed.n - 1, acc: 1 } + \\ } + \\ + \\ var $state = start + \\ + \\ while $state.n != 0 { + \\ $state = { n: $state.n - 1, acc: $state.acc + $state.n } + \\ } + \\ + \\ $state.acc + \\} + \\ + \\main : I64 + \\main = sum_from({ n: 4 }, True) + ; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var unoptimized = try lowerModule(allocator, source, .none); + defer unoptimized.deinit(allocator); + + try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, branchJoinedRecordStateWorkerIsSpecialized)); + try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, branchJoinedRecordStateWorkerIsGeneric)); + + try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, branchJoinedRecordStateWorkerIsSpecialized)); + try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, branchJoinedRecordStateWorkerIsGeneric)); +} + +// Iterator lowering differential harness. +// +// Each `iterdiff:` test lowers ONE Roc source under two inline modes and runs +// both through the interpreter against `RuntimeHostEnv`, then asserts the two +// runs are observationally identical: +// +// * `.wrappers` is the optimized/inlined lowering (the closest proxy the tree +// has for the lower-all-known-wrappers path). +// * `.none` is the naive, un-inlined lowering ("unfused"). +// +// The two runs must agree on: +// * crash-versus-no-crash (`RecordedRun.termination`), and +// * the full ordered host-effect trace (`RecordedRun.events`): every `dbg`, +// `expect` failure, and crash message, in order. +// +// Result VALUES are observed through the effect trace: each pipeline `dbg`s its +// result (and, where useful, each element as it is produced). `dbg` renders a +// value structurally and pointer-independently (e.g. `[6, 8, 10, 12]`), so a +// `dbg` of the collected List/Set output is a complete, allocation-independent +// value assertion that lives inside the compared trace. Ordered per-element +// `dbg`s additionally pin element order and effect ordering (design invariants +// 4 and 5). Allocation counts are intentionally NOT compared: fusing away +// adapter objects legitimately changes how much a run allocates. +// +// A test that fails or crashes here on the current tree is a genuine +// pre-existing divergence between the optimized and naive lowerings, not a test +// bug; such cases are committed commented-out with a `// Pre-existing +// divergence:` marker rather than weakened to pass. + +fn expectRecordedRunsEqual( + expected: eval.RuntimeHostEnv.RecordedRun, + actual: eval.RuntimeHostEnv.RecordedRun, +) TestError!void { + // crash-versus-no-crash + try std.testing.expectEqual(expected.termination, actual.termination); + + // full ordered effect trace (dbg values, expect failures, crash messages) + try std.testing.expectEqual(expected.events.len, actual.events.len); + for (expected.events, actual.events) |expected_event, actual_event| { + try std.testing.expectEqual( + std.meta.activeTag(expected_event), + std.meta.activeTag(actual_event), + ); + try std.testing.expectEqualStrings(expected_event.bytes(), actual_event.bytes()); + } +} + +fn expectSameObservationsAcrossInlineModes(source: []const u8) TestError!void { + const allocator = std.testing.allocator; + + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + var naive = try lowerModule(allocator, source, .none); + defer naive.deinit(allocator); + + var naive_run = try runLoweredWithHostEvents(allocator, &naive.lowered); + defer naive_run.deinit(allocator); + + var optimized_run = try runLoweredWithHostEvents(allocator, &optimized.lowered); + defer optimized_run.deinit(allocator); + + try expectRecordedRunsEqual(naive_run, optimized_run); +} + +test "iterdiff: bounded list map collect agrees across inline modes" { + // Map over a statically-known list, collected into a List, then reduced to a + // scalar. The `dbg` of the collected list is the structural (allocation- + // independent) value assertion; `dbg` of the scalar pins the fold result. + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\main : I64 + \\main = { + \\ doubled : List(I64) + \\ doubled = + \\ [1.I64, 2, 3, 4, 5, 6] + \\ .iter() + \\ .map(|n| n * 2) + \\ .collect() + \\ total = List.sum(doubled) + \\ dbg doubled + \\ dbg total + \\ total + \\} + ); +} + +// A filter-like adapter (`keep_if`) drives a collect loop whose loop-carried +// source iterator advances through a runtime step result. The step callable's +// successor iterator must carry the advanced inner iterator produced by the +// step, so the inner index advances every iteration and the loop terminates. +// Both lowering modes observe the same filtered list. Minimal repro: +// `[1.I64, 2, 3].iter().keep_if(|n| n > 1).collect()` returns `[2, 3]`. +test "iterdiff: bounded list map keep_if collect agrees across inline modes" { + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\main : I64 + \\main = { + \\ doubled : List(I64) + \\ doubled = + \\ [1.I64, 2, 3, 4, 5, 6] + \\ .iter() + \\ .map(|n| n * 2) + \\ .keep_if(|n| n > 5) + \\ .collect() + \\ total = List.sum(doubled) + \\ dbg doubled + \\ dbg total + \\ total + \\} + ); +} + +test "iterdiff: if-chosen iterator chains consumed by one loop agree across inline modes" { + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\main : I64 + \\main = { + \\ threshold = 4.I64 + \\ chosen : Iter(I64) + \\ chosen = + \\ if threshold > 3 { + \\ [1.I64, 2, 3].iter().map(|n| n * 10) + \\ } else { + \\ [4.I64, 5, 6].iter().keep_if(|n| n > 4) + \\ } + \\ var $sum = 0.I64 + \\ for x in chosen { + \\ dbg x + \\ $sum = $sum + x + \\ } + \\ dbg $sum + \\ $sum + \\} + ); +} + +test "iterdiff: branch-chosen append search with early return agrees across inline modes" { + // Rocci's `on_screen_collided!` shape exactly: a zero-accumulator `for` over + // a branch-chosen append chain of record elements that returns early on the + // first match. The branch-append peel factors the shared base iteration out + // and replays the per-element check over each arm's appended items (binding + // each appended record's fields directly); the returned first-match value + // pins the exact pull order (base elements, then appended items in append + // order, with the early return short-circuiting). Both lowerings must return + // the same value for every `(selector, target)` probe. + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\Point : { x : I64, y : I64 } + \\ + \\find : U64, I64 -> I64 + \\find = |selector, target| { + \\ base = [{ x: 10, y: 1 }, { x: 20, y: 2 }, { x: 30, y: 3 }].iter() + \\ chosen = + \\ if selector == 2 { + \\ base.append({ x: 40, y: 4 }).append({ x: 50, y: 5 }) + \\ } else if selector == 1 { + \\ base.append({ x: 60, y: 6 }) + \\ } else { + \\ base + \\ } + \\ for { x, y } in chosen { + \\ if x >= target { + \\ return x + y + \\ } + \\ } + \\ -1 + \\} + \\ + \\main : I64 + \\main = { + \\ a = find(2, 35) + \\ b = find(2, 45) + \\ c = find(2, 100) + \\ d = find(1, 55) + \\ e = find(0, 5) + \\ f = find(0, 100) + \\ dbg a + \\ dbg b + \\ dbg c + \\ dbg d + \\ dbg e + \\ dbg f + \\ a + b + c + d + e + f + \\} + ); +} + +test "iterdiff: set materialized mid-pipeline then iterated agrees across inline modes" { + // Design invariant 4: constructing a Set from the elements really runs, so + // its deduplication happens exactly where written; the pipeline then keeps + // iterating over the materialized result. Both lowerings must observe the + // same deduplicated element sequence and the same collected output. + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\main : I64 + \\main = { + \\ deduped : Set(I64) + \\ deduped = Set.from_list([3.I64, 1, 2, 2, 3, 1, 4, 3]) + \\ doubled : List(I64) + \\ doubled = + \\ deduped + \\ .to_list() + \\ .iter() + \\ .map(|n| n * 2) + \\ .collect() + \\ dbg deduped.to_list() + \\ dbg doubled + \\ List.sum(doubled) + \\} + ); +} + +test "iterdiff: coarse custom is_eq set dedup keeps same representative across inline modes" { + // Design invariant 6: the optimizer must never use a user `is_eq` result to + // substitute one value for another. `Bucket.is_eq` compares only `key`, so + // deduplication is a coarse quotient; `tag` is the representative- + // distinguishing observer. Both lowerings must keep the SAME surviving + // representative (identical ordered `tag` trace), never a different one the + // quotient happens to call equal. + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\Bucket := { key : I64, tag : I64 }.{ + \\ is_eq : Bucket, Bucket -> Bool + \\ is_eq = |a, b| a.key == b.key + \\} + \\ + \\main : I64 + \\main = { + \\ buckets : List(Bucket) + \\ buckets = [ + \\ { key: 1, tag: 100 }, + \\ { key: 2, tag: 200 }, + \\ { key: 1, tag: 999 }, + \\ { key: 2, tag: 888 }, + \\ { key: 3, tag: 300 }, + \\ ] + \\ deduped : Set(Bucket) + \\ deduped = Set.from_list(buckets) + \\ var $tag_sum = 0.I64 + \\ for b in deduped.to_list().iter() { + \\ dbg b.tag + \\ $tag_sum = $tag_sum + b.tag + \\ } + \\ dbg $tag_sum + \\ $tag_sum + \\} + ); +} + +test "iterdiff: stream per-element effects agree across inline modes" { + // Design invariant 5: a Stream pipeline's observable effect trace is the + // per-element, innermost-first pull order, and every lowering must + // reproduce it exactly. The effectful `map!` step `dbg`s each element as it + // is pulled, so the ordered trace pins effect order across inline modes. + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\main : () => List(I64) + \\main = || { + \\ stream = + \\ [1.I64, 2, 3] + \\ .iter() + \\ .stream() + \\ .map!(|n| { + \\ dbg n + \\ n * 2 + \\ }) + \\ result = Stream.collect!(stream) + \\ dbg result + \\ result + \\} + ); +} + +// Pre-existing divergence: a bounded prefix (`take_first`) of an infinite custom +// iterator (`Iter.custom`, the Fibonacci unfold below) diverges between the two +// lowerings, and the seed+step representation does NOT fix it: the divergence is +// an optimizer (spec_constr) miscompile, not a representation issue. The naive +// (`.none`) run yields the correct sequence 0,1,1,2,3,5,8,13; the optimized +// (`.wrappers`) run yields 0,0,0,0,0,0,0,0 (sum 0). Root cause, confirmed from +// the lowered LIR: the custom step correctly computes the advanced `next_seed`, +// but spec_constr rebuilds the successor iterator re-reading the ORIGINAL +// captured seed instead of `next_seed` (the seed's initial value is entry-known, +// so spec_constr treats a runtime-varying loop-carried field as loop-invariant +// and freezes it). The `keep_if` hang above is the same bug on a loop-carried +// iterator box. Activated as an active-failing genuine divergence per the Phase +// 1 gate (both modes disagree). See Phase 1 report for the minimized repro. +test "iterdiff: infinite custom iterator bounded prefix agrees across inline modes" { + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\main : U64 + \\main = { + \\ adv : ((U64, U64) -> Try((U64, (U64, U64)), [NoMore])) + \\ adv = |(a, b)| Try.Ok((a, (b, a + b))) + \\ fib_iter = Iter.custom((0.U64, 1.U64), Unknown, adv) + \\ var $sum = 0.U64 + \\ for f in fib_iter.take_first(8) { + \\ dbg f + \\ $sum = $sum + f + \\ } + \\ dbg $sum + \\ $sum + \\} + ); +} + +// Tier-one LIR identity (iter_fusion_design.md Acceptance item 2). A bounded +// `list.iter().map(f).collect()` whose construction is statically known at its +// consuming loop fuses to the same generated-code loop as a hand-written `for` +// loop: no adapter dispatch, no per-element indirect call, one scalar loop that +// indexes the source list directly. +// +// The comparison is asserted per the principled relation rather than raw +// per-field equality across every field, because two field families cannot +// reach equality for reasons that are inherent to the compared programs, not +// missed fusion (flagged in the Slice D report): +// +// * Consumer allocation strategy. `.collect()` on a bounded iterator knows +// the length up front, so it pre-sizes with `list_with_capacity` and writes +// each element with the unchecked append. A hand-written `for` + `.append` +// is `List.append`, which reserves incrementally (`list_reserve`) and stays +// a per-element call. This is a consumer difference, not an iterator one, so +// `list_with_capacity`/`list_reserve`/`list_append_unsafe`/`direct_call` +// differ by design; the relation (collect pre-sizes, manual grows) is +// asserted instead. +// * Adapter carried box. `map` over a list carries a nested recursive-nominal +// iterator (map wraps the list iterator), whose loop-exit re-materialization +// is Slice E's box (amplified to a nested pair here). The plain list-iterator +// `for` loop carries no such box, so its exit re-materializes nothing. +test "iterdiff: tier-one map collect matches hand-written loop shape" { + const allocator = std.testing.allocator; + const iter_source = + \\module [main] + \\ + \\main : List(I64) + \\main = + \\ [1.I64, 2, 3, 4, 5, 6] + \\ .iter() + \\ .map(|n| n * 2) + \\ .collect() + ; + const loop_source = + \\module [main] + \\ + \\main : List(I64) + \\main = { + \\ var $out = [] + \\ for n in [1.I64, 2, 3, 4, 5, 6] { + \\ $out = $out.append(n * 2) + \\ } + \\ $out + \\} + ; + + var iter_lowered = try lowerModule(allocator, iter_source, .wrappers); + defer iter_lowered.deinit(allocator); + var loop_lowered = try lowerModule(allocator, loop_source, .wrappers); + defer loop_lowered.deinit(allocator); + + const iter = &iter_lowered.lowered; + const loop = &loop_lowered.lowered; + + // Tier-one guarantee: neither side dispatches through an erased adapter + // callable. Both the fused pipeline and the fused hand-written loop drive a + // first-order loop with no `Iter.next` indirection. + inline for (.{ "erased_call_count", "packed_erased_fn_count" }) |field_name| { + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, iter, field_name)); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, loop, field_name)); + } + + // Same fused loop skeleton: one loop join, the same set of back/exit edges, + // and one direct source-list index per element on each side. + inline for (.{ "join_count", "jump_count", "list_get_unsafe_count" }) |field_name| { + const iter_total = try reachableProcShapeFieldTotal(allocator, iter, field_name); + const loop_total = try reachableProcShapeFieldTotal(allocator, loop, field_name); + try std.testing.expectEqual(loop_total, iter_total); + } + + // Consumer allocation strategy differs by design (see header): collect + // pre-sizes, the manual loop grows. + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, iter, "list_with_capacity_count") >= 1); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, loop, "list_with_capacity_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, iter, "list_reserve_count")); + try std.testing.expect(try reachableProcShapeFieldTotal(allocator, loop, "list_reserve_count") >= 1); + + // The adapter and list loop both carry their state as scalar values, so no + // boxed iterator state remains reachable. + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, loop, "box_box_count")); + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, iter, "box_box_count")); +} + +test "iter alloc static: list append append for-loop has no boxed iterator state" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\main : U64 -> Str + \\main = |_seed| { + \\ base_points = [ + \\ { x: 11.I64, y: 2.I64 }, { x: 13, y: 3 }, + \\ { x: 3, y: 5 }, { x: 11, y: 6 }, + \\ { x: 9, y: 8 }, { x: 5, y: 9 }, + \\ { x: 7, y: 10 }, { x: 5, y: 12 }, + \\ ].iter() + \\ collision_points = base_points.append({ x: 2, y: 1 }).append({ x: 7, y: 1 }) + \\ var $sum = 0.I64 + \\ for { x, y } in collision_points { + \\ $sum = $sum + x + y + \\ } + \\ if $sum == 130 { "ok" } else { "bad" } + \\} + ; + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ .tag_reachability = true }); + defer optimized.deinit(allocator); + + try expectReachableProcShapeFieldEqual(allocator, &optimized.lowered, "box_box_count", 0); + try expectNoReachableErasedCallableLowering(allocator, &optimized.lowered); +} + +test "iter alloc static: list append append fold has no boxed iterator state" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\main : U64 -> Str + \\main = |_seed| { + \\ base_points = [ + \\ { x: 11.I64, y: 2.I64 }, { x: 13, y: 3 }, + \\ { x: 3, y: 5 }, { x: 11, y: 6 }, + \\ { x: 9, y: 8 }, { x: 5, y: 9 }, + \\ { x: 7, y: 10 }, { x: 5, y: 12 }, + \\ ].iter() + \\ collision_points = base_points.append({ x: 2, y: 1 }).append({ x: 7, y: 1 }) + \\ sum = Iter.fold(collision_points, 0.I64, |acc, p| acc + p.x + p.y) + \\ if sum == 130 { "ok" } else { "bad" } + \\} + ; + + var optimized = try lowerModuleWithOptions(allocator, source, .wrappers, .{ .tag_reachability = true }); + defer optimized.deinit(allocator); + + try expectReachableProcShapeFieldEqual(allocator, &optimized.lowered, "box_box_count", 0); + try expectNoReachableErasedCallableLowering(allocator, &optimized.lowered); +} + +// Slice H aliasing guard (refcount-exactness for opportunistic mutation). +// +// Slice H turns per-element reads of a loop-carried list into borrows anchored +// on the loop join parameter, dropping the retain/release pair those reads used +// to carry. Roc's in-place mutation is refcount-exact: `List.append` mutates +// its argument in place only when the list is uniquely owned. If the elision +// ever undercounted a shared list, an append would wrongly see it as unique and +// mutate shared data. These tests alias one list into two live consumers (one +// of which would mutate it in place if it looked unique) and assert the naive +// and optimized lowerings observe identical, unmutated values. +test "iterdiff: list aliased into an append and a loop stays unmutated across inline modes" { + // `base` feeds both an append (a would-be in-place mutation) and a loop that + // reads it per element (the Slice H borrow pattern). Because both consumers + // are live, `base` is shared, so the append must copy it. The per-element + // `dbg x`, the final `dbg base`, and `dbg grown` diverge between modes if the + // shared list is ever mutated in place. + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\main : I64 + \\main = { + \\ base : List(I64) + \\ base = [10.I64, 20, 30] + \\ grown : List(I64) + \\ grown = base.append(40) + \\ var $sum = 0.I64 + \\ for x in base.iter() { + \\ dbg x + \\ $sum = $sum + x + \\ } + \\ dbg base + \\ dbg grown + \\ dbg $sum + \\ $sum + \\} + ); +} + +test "iterdiff: loop-carried list appended inside its own loop stays unmutated across inline modes" { + // The list is the loop source (carried across the join and read per element + // as a Slice H borrow) AND is appended inside the body. It is shared for the + // whole loop, so each append must copy it; an in-place mutation of the + // carried source would change later iterations and the final `dbg base`. + try expectSameObservationsAcrossInlineModes( + \\module [main] + \\ + \\main : U64 + \\main = { + \\ base : List(I64) + \\ base = [1.I64, 2, 3] + \\ var $out = [] + \\ for x in base.iter() { + \\ with_x : List(I64) + \\ with_x = base.append(x) + \\ dbg with_x + \\ $out = $out.append(List.len(with_x)) + \\ } + \\ dbg base + \\ dbg $out + \\ List.len($out) + \\} + ); +} + +test "spec constr keeps a same-binder scalar distinct from a substituted aggregate" { + // A source pattern binder is reused across every monomorphization of its + // binding. Here `pair` (a tuple parameter the caller passes a known tuple to, + // so call-pattern specialization substitutes it) and `scalar` (a runtime + // `let` local left un-inlined by a non-substitutable value) deliberately + // share one binder at two monomorphic types. Keying binder-scoped + // substitutions by the binder alone resolves the scalar reference to the + // substituted tuple, materializing a tuple directly inside the result tuple. + // The layout-carrying identity must keep them distinct. + const allocator = std.testing.allocator; + var mono = MonoAst.Program.init(allocator); + var mono_consumed = false; + errdefer if (!mono_consumed) mono.deinit(); + + const shared_binder: check.CheckedModule.PatternBinderId = @enumFromInt(7); + + const u32_ty = try mono.types.add(.{ .primitive = .u32 }); + const pair_span = try mono.types.addSpan(&.{ u32_ty, u32_ty }); + const pair_ty = try mono.types.add(.{ .tuple = pair_span }); + const worker_fn_ty = try mono.types.add(.{ .func = .{ + .args = try mono.types.addSpan(&.{pair_ty}), + .ret = pair_ty, + } }); + const worker_fn_id = try mono.addFn(.{ + .fn_def = undefined, + .source_fn_ty = undefined, + .source_fn_key = .{}, + .mono_fn_ty = worker_fn_ty, + }); + + const opaque_scalar = try mono.addImportedFn(.{ .shard = @enumFromInt(1), .fn_id = @enumFromInt(1) }); + + const pair_local = try mono.addLocalWithBinder(@enumFromInt(1), pair_ty, shared_binder); + const scalar_local = try mono.addLocalWithBinder(@enumFromInt(2), u32_ty, shared_binder); + + const scalar_value = try mono.addExpr(.{ .ty = u32_ty, .data = .{ .call_proc = .{ + .callee = MonoAst.importedProcCallee(opaque_scalar), + .args = MonoAst.Span(MonoAst.ExprId).empty(), + } } }); + const scalar_pat = try mono.addPat(.{ .ty = u32_ty, .data = .{ .bind = scalar_local } }); + + const pair_ref = try mono.addExpr(.{ .ty = pair_ty, .data = .{ .local = pair_local } }); + const pair_first = try mono.addExpr(.{ .ty = u32_ty, .data = .{ .tuple_access = .{ .tuple = pair_ref, .elem_index = 0 } } }); + const scalar_ref = try mono.addExpr(.{ .ty = u32_ty, .data = .{ .local = scalar_local } }); + const result_pair = try mono.addExpr(.{ .ty = pair_ty, .data = .{ .tuple = try mono.addExprSpan(&.{ pair_first, scalar_ref }) } }); + const worker_body = try mono.addExpr(.{ .ty = pair_ty, .data = .{ .let_ = .{ + .bind = scalar_pat, + .value = scalar_value, + .rest = result_pair, + } } }); + + try mono.defs.append(allocator, .{ + .symbol = @enumFromInt(10), + .fn_id = worker_fn_id, + .args = try mono.addTypedLocalSpan(&.{.{ .local = pair_local, .ty = pair_ty }}), + .body = .{ .roc = worker_body }, + .ret = pair_ty, + }); + + const lit_a = try mono.addExpr(.{ .ty = u32_ty, .data = .{ .int_lit = .{ .bytes = @bitCast(@as(u128, 3)), .kind = .u128 } } }); + const lit_b = try mono.addExpr(.{ .ty = u32_ty, .data = .{ .int_lit = .{ .bytes = @bitCast(@as(u128, 4)), .kind = .u128 } } }); + const call_arg = try mono.addExpr(.{ .ty = pair_ty, .data = .{ .tuple = try mono.addExprSpan(&.{ lit_a, lit_b }) } }); + const caller_body = try mono.addExpr(.{ .ty = pair_ty, .data = .{ .call_proc = .{ + .callee = MonoAst.localProcCallee(worker_fn_id), + .args = try mono.addExprSpan(&.{call_arg}), + } } }); + try mono.defs.append(allocator, .{ + .symbol = @enumFromInt(11), + .args = MonoAst.Span(MonoAst.TypedLocal).empty(), + .body = .{ .roc = caller_body }, + .ret = pair_ty, + }); + + var lifted = try postcheck.MonotypeLifted.Lift.run(allocator, mono); + mono_consumed = true; + defer lifted.deinit(); + + try postcheck.MonotypeLifted.SpecConstr.run(allocator, &lifted); + try postcheck.MonotypeLifted.Lift.recomputeCaptures(allocator, &lifted); + + // The input program has no tuple nested directly inside another tuple, so a + // nested tuple after specialization means the substituted aggregate leaked + // into the scalar slot. + for (lifted.exprsView()) |expr| { + const items = switch (expr.data) { + .tuple => |items| items, + else => continue, + }; + const tuple_items = lifted.exprSpan(items); + for (0..tuple_items.len) |index| { + const item = GuardedList.at(tuple_items, index); + switch (lifted.getExpr(item).data) { + .tuple => return error.SubstitutedAggregateLeakedIntoScalar, + else => {}, + } + } + } +} + +fn bareListIterCollectLoopIsScalar(shape: ProcShape) bool { + return shape.join_count >= 1 and + shape.max_join_param_count >= 5 and + shape.list_get_unsafe_count >= 1 and + shape.list_append_unsafe_count >= 1 and + shape.erased_call_count == 0 and + shape.direct_call_count == 0; +} + +test "bare list iter collect carries scalar list state in the loop" { + const allocator = std.testing.allocator; + const source = + \\module [main] + \\ + \\main : () -> List(I64) + \\main = || [1.I64, 2, 3].iter().collect() + ; + var optimized = try lowerModule(allocator, source, .wrappers); + defer optimized.deinit(allocator); + + // The consumer loop carries the list-iter state as scalar loop variables + // (length payload, list, index) plus the output list, and indexes the + // list directly per element. No reachable proc dispatches through the + // erased step callable and no per-element call remains. + try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, bareListIterCollectLoopIsScalar)); + try std.testing.expect(!try reachableIterCollectShape(allocator, &optimized.lowered, .generic)); + try std.testing.expect(!try reachableIterCollectShape(allocator, &optimized.lowered, .specialized)); + try expectNoReachableErasedCallableLowering(allocator, &optimized.lowered); + // The list-iter carries its step closure inline by value, so the loop state + // needs no boxed iterator state at all; the only allocation is the output + // list itself. + try std.testing.expectEqual(@as(usize, 0), try reachableProcShapeFieldTotal(allocator, &optimized.lowered, "box_box_count")); +} diff --git a/src/eval/test/parallel_runner.zig b/src/eval/test/parallel_runner.zig index 018b1b7531f..768c5f83480 100644 --- a/src/eval/test/parallel_runner.zig +++ b/src/eval/test/parallel_runner.zig @@ -132,6 +132,7 @@ pub const TestCase = struct { pub const AllocationExpectation = struct { output: []const u8, max_allocations: u32, + optimized: bool = false, }; pub const Skip = packed struct { @@ -678,8 +679,11 @@ fn runSingleTest(io: std.Io, allocator: std.mem.Allocator, tc: TestCase, timeout .typecheck_ns = compiled.resources.typecheck_ns, }; }, - .allocations_at_most => blk: { - var compiled = helpers.compileProgram(allocator, io, tc.source_kind, tc.source, tc.imports) catch { + .allocations_at_most => |expected| blk: { + var compiled = (if (expected.optimized) + helpers.compileAllocationProgram(allocator, io, tc.source_kind, tc.source, tc.imports) + else + helpers.compileProgram(allocator, io, tc.source_kind, tc.source, tc.imports)) catch { return .{ .status = .fail, .message = "INVALID_SYNTAX — skipped allocation test has parse/check/lower errors", @@ -829,7 +833,10 @@ fn runAllocationTest( expected: TestCase.AllocationExpectation, skip: TestCase.Skip, ) RunnerError!TestOutcome { - var compiled = try helpers.compileProgram(allocator, io, source_kind, src, imports); + var compiled = if (expected.optimized) + try helpers.compileAllocationProgram(allocator, io, source_kind, src, imports) + else + try helpers.compileProgram(allocator, io, source_kind, src, imports); defer compiled.deinit(allocator); const timings = EvalTimings{ diff --git a/src/eval/test/trmc_lir_test.zig b/src/eval/test/trmc_lir_test.zig index 08dfb2a8a5b..69f47c8ced9 100644 --- a/src/eval/test/trmc_lir_test.zig +++ b/src/eval/test/trmc_lir_test.zig @@ -56,11 +56,16 @@ const ProcBuilder = struct { }; fn lowLevelStmt(store: *LirStore, target: LocalId, op: LowLevel, args: []const LocalId, next: CFStmtId) TrmcLirTestError!CFStmtId { + return try lowLevelStmtWithUnique(store, target, op, args, 0, next); +} + +fn lowLevelStmtWithUnique(store: *LirStore, target: LocalId, op: LowLevel, args: []const LocalId, unique_args: u64, next: CFStmtId) TrmcLirTestError!CFStmtId { return try store.addCFStmt(.{ .assign_low_level = .{ .target = target, .op = op, .rc_effect = op.rcEffect(), .args = try store.addLocalSpan(args), + .unique_args = unique_args, .next = next, } }); } @@ -166,6 +171,123 @@ test "box_alloc_zeroed cell is zeroed, writable through ptr_cast, and freed by d try runtime_env.checkForLeaks(); } +test "box_prepare_update reuses a statically unique box" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout.Store.init(allocator, base.target.TargetUsize.native); + defer layouts.deinit(); + var runtime_env = RuntimeHostEnv.init(allocator); + defer runtime_env.deinit(); + + const box_u64 = try layouts.insertBox(.u64); + const ptr_u64 = try layouts.insertPtr(.u64); + + var b = ProcBuilder.init(&store); + defer b.deinit(allocator); + const initial = try b.addLocal(allocator, .u64); + const replacement = try b.addLocal(allocator, .u64); + const boxed = try b.addLocal(allocator, box_u64); + const prepared = try b.addLocal(allocator, box_u64); + const p = try b.addLocal(allocator, ptr_u64); + const st = try b.addLocal(allocator, .zst); + const loaded = try b.addLocal(allocator, .u64); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = loaded } }); + const drop_prepared = try store.addCFStmt(.{ .decref = .{ + .value = prepared, + .rc = .{ .op = .decref, .layout_idx = box_u64 }, + .next = ret, + } }); + const load = try lowLevelStmt(&store, loaded, .ptr_load, &.{p}, drop_prepared); + const store_replacement = try lowLevelStmt(&store, st, .ptr_store, &.{ p, replacement }, load); + const replacement_lit = try store.addCFStmt(.{ .assign_literal = .{ + .target = replacement, + .value = .{ .i64_literal = .{ .value = 7, .layout_idx = .u64 } }, + .next = store_replacement, + } }); + const cast = try lowLevelStmt(&store, p, .ptr_cast, &.{prepared}, replacement_lit); + const prepare = try lowLevelStmtWithUnique(&store, prepared, .box_prepare_update, &.{boxed}, 1, cast); + const box_initial = try lowLevelStmt(&store, boxed, .box_box, &.{initial}, prepare); + const initial_lit = try store.addCFStmt(.{ .assign_literal = .{ + .target = initial, + .value = .{ .i64_literal = .{ .value = 5, .layout_idx = .u64 } }, + .next = box_initial, + } }); + const proc = try b.finishProc(&.{}, initial_lit, .u64); + + try std.testing.expectEqual(@as(u64, 7), try runProcU64(allocator, &store, &layouts, proc, &runtime_env)); + try std.testing.expectEqual(@as(u32, 1), runtime_env.allocationCallCount()); + try runtime_env.checkForLeaks(); +} + +test "box_prepare_update copies a shared box and leaves the original unchanged" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout.Store.init(allocator, base.target.TargetUsize.native); + defer layouts.deinit(); + var runtime_env = RuntimeHostEnv.init(allocator); + defer runtime_env.deinit(); + + const box_u64 = try layouts.insertBox(.u64); + const ptr_u64 = try layouts.insertPtr(.u64); + + var b = ProcBuilder.init(&store); + defer b.deinit(allocator); + const initial = try b.addLocal(allocator, .u64); + const replacement = try b.addLocal(allocator, .u64); + const boxed = try b.addLocal(allocator, box_u64); + const prepared = try b.addLocal(allocator, box_u64); + const old_p = try b.addLocal(allocator, ptr_u64); + const new_p = try b.addLocal(allocator, ptr_u64); + const st = try b.addLocal(allocator, .zst); + const old_value = try b.addLocal(allocator, .u64); + const new_value = try b.addLocal(allocator, .u64); + const sum = try b.addLocal(allocator, .u64); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = sum } }); + const drop_boxed = try store.addCFStmt(.{ .decref = .{ + .value = boxed, + .rc = .{ .op = .decref, .layout_idx = box_u64 }, + .next = ret, + } }); + const drop_prepared = try store.addCFStmt(.{ .decref = .{ + .value = prepared, + .rc = .{ .op = .decref, .layout_idx = box_u64 }, + .next = drop_boxed, + } }); + const add = try lowLevelStmt(&store, sum, .num_plus, &.{ old_value, new_value }, drop_prepared); + const load_new = try lowLevelStmt(&store, new_value, .ptr_load, &.{new_p}, add); + const load_old = try lowLevelStmt(&store, old_value, .ptr_load, &.{old_p}, load_new); + const store_replacement = try lowLevelStmt(&store, st, .ptr_store, &.{ new_p, replacement }, load_old); + const replacement_lit = try store.addCFStmt(.{ .assign_literal = .{ + .target = replacement, + .value = .{ .i64_literal = .{ .value = 7, .layout_idx = .u64 } }, + .next = store_replacement, + } }); + const cast_new = try lowLevelStmt(&store, new_p, .ptr_cast, &.{prepared}, replacement_lit); + const cast_old = try lowLevelStmt(&store, old_p, .ptr_cast, &.{boxed}, cast_new); + const prepare = try lowLevelStmt(&store, prepared, .box_prepare_update, &.{boxed}, cast_old); + const incref_boxed = try store.addCFStmt(.{ .incref = .{ + .value = boxed, + .rc = .{ .op = .incref, .layout_idx = box_u64 }, + .count = 1, + .next = prepare, + } }); + const box_initial = try lowLevelStmt(&store, boxed, .box_box, &.{initial}, incref_boxed); + const initial_lit = try store.addCFStmt(.{ .assign_literal = .{ + .target = initial, + .value = .{ .i64_literal = .{ .value = 5, .layout_idx = .u64 } }, + .next = box_initial, + } }); + const proc = try b.finishProc(&.{}, initial_lit, .u64); + + try std.testing.expectEqual(@as(u64, 12), try runProcU64(allocator, &store, &layouts, proc, &runtime_env)); + try std.testing.expectEqual(@as(u32, 2), runtime_env.allocationCallCount()); + try runtime_env.checkForLeaks(); +} + test "ptr ops round trip a multi-word payload through a heap cell" { const allocator = std.testing.allocator; var store = LirStore.init(allocator); @@ -419,7 +541,7 @@ fn hasSelfCall(allocator: Allocator, store: *const LirStore, proc_id: LIR.LirPro try work.append(allocator, s.on_miss); }, .jump, .ret, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try work.append(allocator, s.next); }, } diff --git a/src/eval/test_helpers.zig b/src/eval/test_helpers.zig index f8c80a57437..a0e8a2d45a1 100644 --- a/src/eval/test_helpers.zig +++ b/src/eval/test_helpers.zig @@ -690,17 +690,42 @@ pub fn compileProgram( source_kind: SourceKind, source: []const u8, imports: []const ModuleSource, +) TestHelperError!CompiledProgram { + return compileProgramWithOptions(allocator, io, source_kind, source, imports, .{}); +} + +/// Parse, canonicalize, type-check, and lower with allocation-test options. +pub fn compileAllocationProgram( + allocator: Allocator, + io: std.Io, + source_kind: SourceKind, + source: []const u8, + imports: []const ModuleSource, +) TestHelperError!CompiledProgram { + return compileProgramWithOptions(allocator, io, source_kind, source, imports, .{ + .inline_mode = .wrappers, + .tag_reachability = true, + }); +} + +fn compileProgramWithOptions( + allocator: Allocator, + io: std.Io, + source_kind: SourceKind, + source: []const u8, + imports: []const ModuleSource, + options: LowerToLirOptions, ) TestHelperError!CompiledProgram { var resources = try parseAndCanonicalizeProgramWrapped(allocator, source_kind, source, imports, false); errdefer cleanupParseAndCanonical(allocator, resources); - const lowered = try lowerParsedProgramToLir(allocator, io, &resources, .native); + const lowered = try lowerParsedProgramToLirWithOptions(allocator, io, &resources, .native, options); errdefer { var owned = lowered; owned.deinit(allocator); } - const wasm_lowered = try lowerParsedProgramToLir(allocator, io, &resources, .u32); + const wasm_lowered = try lowerParsedProgramToLirWithOptions(allocator, io, &resources, .u32, options); errdefer { var owned = wasm_lowered; owned.deinit(allocator); @@ -982,6 +1007,30 @@ fn publishProgramForComptimeProblemsImpl( .comptime_problems; } +/// Publish a program with compile-time evaluation problems routed into each +/// module's checker problem store and return the full resources for tests that +/// need to inspect which module received which diagnostic. Unlike +/// `publishProgramForComptimeProblems`, this only returns resources when +/// publishing completes without a blocking compile-time problem; crashing roots +/// and failed expects still return `error.CompileTimeProblem`. +pub fn publishProgramKeepingReportedComptimeProblems( + allocator: Allocator, + source_kind: SourceKind, + source: []const u8, + imports: []const ModuleSource, +) TestHelperError!ParsedResources { + return parseAndCanonicalizeProgramWithRootModeReporting( + allocator, + source_kind, + source, + imports, + false, + .published_roots_only, + null, + .report_comptime_problems, + ); +} + const PublishedRootMode = union(enum) { eval_root: bool, published_roots_only, @@ -1165,6 +1214,7 @@ fn parseAndCanonicalizeProgramWithRootModeReporting( extra_modules.items, &builtin_module_owned_by_artifact, pre_published_builtin, + problem_reporting, ); errdefer { for (import_artifacts) |*artifact| artifact.deinit(allocator); @@ -1384,9 +1434,24 @@ fn lowerParsedProgramToLir( io: std.Io, resources: *ParsedResources, target_usize: base.target.TargetUsize, +) TestHelperError!LoweredProgram { + return lowerParsedProgramToLirWithOptions(allocator, io, resources, target_usize, .{}); +} + +const LowerToLirOptions = struct { + inline_mode: lir.CheckedPipeline.InlineMode = .none, + tag_reachability: bool = false, +}; + +fn lowerParsedProgramToLirWithOptions( + allocator: Allocator, + io: std.Io, + resources: *ParsedResources, + target_usize: base.target.TargetUsize, + options: LowerToLirOptions, ) TestHelperError!LoweredProgram { if (resources.borrowed_builtin_artifact == null) { - return lowerCheckedModuleSetToLir(allocator, io, &resources.checked_artifact, resources.import_artifacts, target_usize); + return lowerCheckedModuleSetToLirWithOptions(allocator, io, &resources.checked_artifact, resources.import_artifacts, target_usize, options); } const borrowed = resources.borrowed_builtin_artifact.?; @@ -1397,7 +1462,7 @@ fn lowerParsedProgramToLir( for (resources.import_artifacts, 0..) |*module, i| { import_views[i + 1] = check.CheckedArtifact.importedView(module); } - return lowerCheckedRootWithViews(allocator, io, &resources.checked_artifact, import_views, target_usize); + return lowerCheckedRootWithViews(allocator, io, &resources.checked_artifact, import_views, target_usize, options); } /// Lower already-published checked modules to a LIR image. @@ -1407,13 +1472,24 @@ pub fn lowerCheckedModuleSetToLir( root_module: *check.CheckedArtifact.CheckedModuleArtifact, import_modules: []check.CheckedArtifact.CheckedModuleArtifact, target_usize: base.target.TargetUsize, +) TestHelperError!LoweredProgram { + return lowerCheckedModuleSetToLirWithOptions(allocator, io, root_module, import_modules, target_usize, .{}); +} + +fn lowerCheckedModuleSetToLirWithOptions( + allocator: Allocator, + io: std.Io, + root_module: *check.CheckedArtifact.CheckedModuleArtifact, + import_modules: []check.CheckedArtifact.CheckedModuleArtifact, + target_usize: base.target.TargetUsize, + options: LowerToLirOptions, ) TestHelperError!LoweredProgram { const import_views = try allocator.alloc(check.CheckedArtifact.ImportedModuleView, import_modules.len); defer allocator.free(import_views); for (import_modules, 0..) |*module, i| { import_views[i] = check.CheckedArtifact.importedView(module); } - return lowerCheckedRootWithViews(allocator, io, root_module, import_views, target_usize); + return lowerCheckedRootWithViews(allocator, io, root_module, import_views, target_usize, options); } fn lowerCheckedRootWithViews( @@ -1422,6 +1498,7 @@ fn lowerCheckedRootWithViews( root_module: *check.CheckedArtifact.CheckedModuleArtifact, import_views: []const check.CheckedArtifact.ImportedModuleView, target_usize: base.target.TargetUsize, + options: LowerToLirOptions, ) TestHelperError!LoweredProgram { const page_size = try SharedMemoryAllocator.getSystemPageSize(); var shm = try SharedMemoryAllocator.createWithMinSize(io, EVAL_SHARED_MEMORY_SIZE, EVAL_SHARED_MEMORY_MIN_SIZE, page_size); @@ -1439,10 +1516,12 @@ fn lowerCheckedRootWithViews( .{ .requests = root_module.root_requests.runtime_requests }, .{ .target_usize = target_usize, + .inline_mode = options.inline_mode, // Match optimized builds so every backend exercises the in-place // List.map path; the copy path is still covered by shared-list, // slice, and layout-mismatch cases. .list_in_place_map = true, + .tag_reachability = options.tag_reachability, }, ); @@ -1477,6 +1556,7 @@ fn publishImportArtifacts( extra_modules: []CheckedModule, builtin_module_owned_by_artifact: *bool, pre_published_builtin: ?PrePublishedBuiltin, + problem_reporting: ComptimeProblemReporting, ) TestHelperError![]check.CheckedArtifact.CheckedModuleArtifact { const extra_module_count = extra_modules.len; var artifacts = std.ArrayList(check.CheckedArtifact.CheckedModuleArtifact).empty; @@ -1548,6 +1628,10 @@ fn publishImportArtifacts( .imports = published_keys.items, .available_artifacts = available_artifacts, .compile_time_finalizer = CompileTimeFinalization.finalizer(), + .problem_store = switch (problem_reporting) { + .ignore_comptime_problems => null, + .report_comptime_problems => &extra_modules[extra_i].checker.problems, + }, }, ); extra_modules[extra_i].published_owns_module_env = true; diff --git a/src/fmt/fmt.zig b/src/fmt/fmt.zig index d286bad3090..a40cad81da1 100644 --- a/src/fmt/fmt.zig +++ b/src/fmt/fmt.zig @@ -1010,6 +1010,53 @@ const Formatter = struct { try fmt.push(braces.end()); } + fn formatApplyArgs(fmt: *Formatter, region: AST.TokenizedRegion, args: []AST.Expr.Idx) FormatAstError!void { + if (try fmt.formatSingleMultilineCollectionArg(region, args)) { + return; + } + + try fmt.formatCollection(region, .round, AST.Expr.Idx, args, Formatter.formatExpr); + } + + fn formatSingleMultilineCollectionArg(fmt: *Formatter, region: AST.TokenizedRegion, args: []AST.Expr.Idx) FormatAstError!bool { + if (!fmt.hasSingleMultilineCollectionArg(region, args)) { + return false; + } + + try fmt.push('('); + try fmt.formatExprDiscard(args[0]); + try fmt.push(')'); + return true; + } + + fn hasSingleMultilineCollectionArg(fmt: *Formatter, region: AST.TokenizedRegion, args: []AST.Expr.Idx) bool { + if (args.len != 1) { + return false; + } + + const arg_idx = args[0]; + const arg = fmt.ast.store.getExpr(arg_idx); + switch (arg) { + .record, .list, .tuple => {}, + else => return false, + } + + if (!fmt.nodeWillBeMultiline(AST.Expr.Idx, arg_idx)) { + return false; + } + + const arg_region = fmt.nodeRegion(@intFromEnum(arg_idx)); + if (fmt.hasCommentBefore(arg_region.start)) { + return false; + } + + if (region.end > 0 and fmt.hasCommentBefore(region.end - 1)) { + return false; + } + + return true; + } + /// Format a record type annotation with an extension (e.g., { name: Str, ..ext } or { name: Str, .. }) fn formatRecordWithExtension(fmt: *Formatter, fields_span: AST.AnnoRecordField.Span, ext: AST.TypeAnno.RecordExt, record_region: AST.TokenizedRegion) FormatAstError!void { const fields = fmt.ast.store.annoRecordFieldSlice(fields_span); @@ -1220,7 +1267,7 @@ const Formatter = struct { try fmt.formatExprDiscard(a.@"fn"); const fn_region = fmt.nodeRegion(@intFromEnum(a.@"fn")); const args_region = AST.TokenizedRegion{ .start = fn_region.end, .end = region.end }; - try fmt.formatCollection(args_region, .round, AST.Expr.Idx, fmt.ast.store.exprSlice(a.args), Formatter.formatExpr); + try fmt.formatApplyArgs(args_region, fmt.ast.store.exprSlice(a.args)); }, .string_part => |s| { try fmt.pushTokenText(s.token); @@ -1389,7 +1436,7 @@ const Formatter = struct { // `mc.region` would include newlines from the receiver chain and // wrongly expand short, inline arguments. (See issue #9646) const args_region = AST.TokenizedRegion{ .start = mc.method_token + 1, .end = mc.region.end }; - try fmt.formatCollection(args_region, .round, AST.Expr.Idx, fmt.ast.store.exprSlice(mc.args), Formatter.formatExpr); + try fmt.formatApplyArgs(args_region, fmt.ast.store.exprSlice(mc.args)); }, .arrow_call => |ld| { const left = try fmt.formatExprWithInfo(ld.left); @@ -1437,7 +1484,7 @@ const Formatter = struct { const right_region = fmt.nodeRegion(@intFromEnum(ld.right)); const fn_region = fmt.nodeRegion(@intFromEnum(apply_fn_idx)); const args_region = AST.TokenizedRegion{ .start = fn_region.end, .end = right_region.end }; - try fmt.formatCollection(args_region, .round, AST.Expr.Idx, fmt.ast.store.exprSlice(apply.args), Formatter.formatExpr); + try fmt.formatApplyArgs(args_region, fmt.ast.store.exprSlice(apply.args)); } else { try fmt.formatExprInnerDiscard(ld.right, .no_indent_on_access); } @@ -2778,12 +2825,14 @@ const Formatter = struct { if (multiline and try fmt.flushCommentsAfter(arg_region.end - 1)) { fmt.curr_indent += 1; try fmt.pushIndent(); - try fmt.pushAll("->"); + try fmt.pushAll(if (c.effectful) "=>" else "->"); } else { - try fmt.pushAll(" ->"); + try fmt.pushAll(if (c.effectful) " =>" else " ->"); } } } + } else if (c.effectful) { + try fmt.pushAll(" () =>"); } if (multiline and try fmt.flushCommentsBefore(ret_region.start)) { fmt.curr_indent += 1; @@ -3637,6 +3686,19 @@ test "issue 8894: typed frac literal formats correctly" { try std.testing.expectEqualStrings("x = 3.14.F64\n", result); } +test "effectful where-clause method arrows are preserved" { + const result = try moduleFmtsStable(std.testing.allocator, + \\uses_tick : a => U64 where [a.tick! : a => U64, a.next! : () => U64] + \\uses_tick = |x| x.tick!() + , false); + defer std.testing.allocator.free(result); + try std.testing.expectEqualStrings( + \\uses_tick : a => U64 where [a.tick! : a => U64, a.next! : () => U64] + \\uses_tick = |x| x.tick!() + \\ + , result); +} + test "issue 9646: multiline method chain keeps short args inline without trailing comma" { // In a multiline method chain, each method-call argument that fits on one // line and has no input trailing comma should stay inline, not get expanded @@ -3661,6 +3723,68 @@ test "issue 9646: multiline method chain keeps short args inline without trailin ); } +test "single multiline collection literal apply args keep call paren tight" { + const result = try moduleFmtsStable(std.testing.allocator, + \\record_arg = f( + \\ { + \\ x: 1, + \\ y: 2, + \\ }, + \\) + \\list_arg = f( + \\ [ + \\ 1, + \\ 2, + \\ ], + \\) + \\tuple_arg = f( + \\ ( + \\ 1, + \\ 2, + \\ ), + \\) + , false); + defer std.testing.allocator.free(result); + + const expected = + "record_arg = f({\n" ++ + "\tx: 1,\n" ++ + "\ty: 2,\n" ++ + "})\n" ++ + "\n" ++ + "list_arg = f([\n" ++ + "\t1,\n" ++ + "\t2,\n" ++ + "])\n" ++ + "\n" ++ + "tuple_arg = f((\n" ++ + "\t1,\n" ++ + "\t2,\n" ++ + "))\n"; + try std.testing.expectEqualStrings(expected, result); +} + +test "single multiline collection literal method args keep call paren tight" { + const result = try moduleFmtsStable(std.testing.allocator, + \\sprite = base + \\ .pos( + \\ { + \\ x: 1, + \\ y: 2, + \\ }, + \\ ) + , false); + defer std.testing.allocator.free(result); + + const expected = + "sprite = base\n" ++ + "\t.pos({\n" ++ + "\t\tx: 1,\n" ++ + "\t\ty: 2,\n" ++ + "\t})\n"; + try std.testing.expectEqualStrings(expected, result); +} + test "issue 9939: named open tag union type variable is preserved" { const result = try moduleFmtsStable(std.testing.allocator, "T(a) : [..a]", false); defer std.testing.allocator.free(result); diff --git a/src/lir/LIR.zig b/src/lir/LIR.zig index ee878331e10..b2472b45fe6 100644 --- a/src/lir/LIR.zig +++ b/src/lir/LIR.zig @@ -139,6 +139,11 @@ pub const StrLiteral = struct { len: u32, }; +/// Identifier for one readonly data object emitted by static-data materialization. +pub const StaticDataId = enum(u32) { + _, +}; + /// How a string interpolation pattern must finish after its last step. pub const StrPatternEnd = enum { exact, @@ -267,6 +272,7 @@ pub const LiteralValue = union(enum) { f32_literal: f32, dec_literal: i128, str_literal: StrLiteral, + static_data: StaticDataId, bytes_literal: StrLiteral, null_ptr, proc_ref: LirProcSpecId, @@ -443,6 +449,15 @@ pub const CFStmt = union(enum) { capture: ?LocalId, capture_layout: ?layout.Idx, on_drop: ErasedCallableOnDrop, + /// Optional consumed erased callable allocation to repack. + /// + /// When present, this statement returns a unique erased callable with + /// the new proc/drop/capture. If `reuse_unique` is true, ARC proved the + /// consumed allocation is uniquely owned at the statement. Otherwise, + /// consumers must runtime-check uniqueness and take the fresh allocate + /// path when the old allocation is shared. + reuse: ?LocalId = null, + reuse_unique: bool = false, next: CFStmtId, }, assign_low_level: struct { @@ -484,6 +499,20 @@ pub const CFStmt = union(enum) { payload: ?LocalId, next: CFStmtId, }, + store_struct: struct { + dest: LocalId, + struct_layout: layout.Idx, + fields: LocalSpan, + next: CFStmtId, + }, + store_tag: struct { + dest: LocalId, + tag_layout: layout.Idx, + variant_index: u16, + discriminant: u16, + payload: ?LocalId, + next: CFStmtId, + }, set_local: struct { target: LocalId, value: LocalId, diff --git a/src/lir/LirStore.zig b/src/lir/LirStore.zig index af5ca0c3456..c5df372f300 100644 --- a/src/lir/LirStore.zig +++ b/src/lir/LirStore.zig @@ -417,6 +417,8 @@ pub fn addCFStmt(self: *Self, stmt: CFStmt) Allocator.Error!CFStmtId { .assign_list, .assign_struct, .assign_tag, + .store_struct, + .store_tag, .set_local, .debug, .expect, diff --git a/src/lir/arc.zig b/src/lir/arc.zig index 4bc6dc7552c..f5b3bfbc77e 100644 --- a/src/lir/arc.zig +++ b/src/lir/arc.zig @@ -198,8 +198,9 @@ pub fn insert(store: *LirStore, layouts: *const layout_mod.Store, options: Inser } } const rewritten_body = try inserter.rewritePath(body, &owned, .{}); - const join_points = try inserter.procJoinPoints(rewritten_body); - store.setProcSpecBodyAndJoinPoints(emit_proc, rewritten_body, join_points); + const elided_body = try elideImmediateRcPairs(store, rewritten_body); + const join_points = try inserter.procJoinPoints(elided_body); + store.setProcSpecBodyAndJoinPoints(emit_proc, elided_body, join_points); } if (builtin.mode == .Debug) { @@ -837,6 +838,36 @@ const Inserter = struct { }); path.cursor = assign.next; }, + .store_struct => |assign| { + const transfer_mask = try self.spanTransferMask(assign.fields, ~@as(u64, 0), assign.next, assign.dest, &path.owned, path.options.loop_keep); + var deaths: std.ArrayList(LIR.LocalId) = .empty; + errdefer deaths.deinit(self.store.allocator); + try self.postStmtDeaths(&path.owned, &.{}, assign.fields, assign.next, path.options.loop_keep, &deaths); + try path.frames.append(self.store.allocator, .{ + .stmt = path.cursor, + .head = current_start, + .transfer_mask = transfer_mask, + .post_release = try self.takePostReleases(&deaths), + }); + path.cursor = assign.next; + }, + .store_tag => |assign| { + var transfer_single = false; + if (assign.payload) |payload| { + transfer_single = try self.singleTransfer(payload, assign.next, assign.dest, &path.owned, path.options.loop_keep); + } + var deaths: std.ArrayList(LIR.LocalId) = .empty; + errdefer deaths.deinit(self.store.allocator); + const singles = [_]LIR.LocalId{assign.payload orelse assign.dest}; + try self.postStmtDeaths(&path.owned, &singles, null, assign.next, path.options.loop_keep, &deaths); + try path.frames.append(self.store.allocator, .{ + .stmt = path.cursor, + .head = current_start, + .transfer_single = transfer_single, + .post_release = try self.takePostReleases(&deaths), + }); + path.cursor = assign.next; + }, .set_local => |assign| { const transfer = try self.transferForSetLocal(&path.owned, assign.target, assign.value, assign.mode, assign.next, path.options.loop_keep); if (transfer.release_old_target) { @@ -1146,6 +1177,30 @@ const Inserter = struct { .next = next, } }); }, + .store_struct => |assign| { + next = try self.retainSpanExcept(assign.fields, frame.transfer_mask, next); + cloned = try self.store.addCFStmt(.{ .store_struct = .{ + .dest = assign.dest, + .struct_layout = assign.struct_layout, + .fields = assign.fields, + .next = next, + } }); + }, + .store_tag => |assign| { + if (assign.payload) |payload| { + if (!frame.transfer_single) { + next = try self.retainLocalIfRc(payload, next); + } + } + cloned = try self.store.addCFStmt(.{ .store_tag = .{ + .dest = assign.dest, + .tag_layout = assign.tag_layout, + .variant_index = assign.variant_index, + .discriminant = assign.discriminant, + .payload = assign.payload, + .next = next, + } }); + }, .set_local => |assign| { if (assign.target != assign.value and frame.retain_set_target) { next = try self.retainLocalIfRc(assign.target, next); @@ -1924,6 +1979,19 @@ const Inserter = struct { try self.postStmtDeaths(&path.owned, &singles, null, assign.next, path.loop_keep, null); path.cursor = assign.next; }, + .store_struct => |assign| { + _ = try self.spanTransferMask(assign.fields, ~@as(u64, 0), assign.next, assign.dest, &path.owned, path.loop_keep); + try self.postStmtDeaths(&path.owned, &.{}, assign.fields, assign.next, path.loop_keep, null); + path.cursor = assign.next; + }, + .store_tag => |assign| { + if (assign.payload) |payload| { + _ = try self.singleTransfer(payload, assign.next, assign.dest, &path.owned, path.loop_keep); + } + const singles = [_]LIR.LocalId{assign.payload orelse assign.dest}; + try self.postStmtDeaths(&path.owned, &singles, null, assign.next, path.loop_keep, null); + path.cursor = assign.next; + }, .set_local => |assign| { _ = try self.transferForSetLocal(&path.owned, assign.target, assign.value, assign.mode, assign.next, path.loop_keep); const singles = [_]LIR.LocalId{ assign.value, assign.target }; @@ -3191,6 +3259,8 @@ const Inserter = struct { .assign_list, .assign_struct, .assign_tag, + .store_struct, + .store_tag, .set_local, .debug, .expect, @@ -3430,6 +3500,8 @@ const Inserter = struct { .assign_list => |assign| try stack.append(self.store.allocator, assign.next), .assign_struct => |assign| try stack.append(self.store.allocator, assign.next), .assign_tag => |assign| try stack.append(self.store.allocator, assign.next), + .store_struct => |assign| try stack.append(self.store.allocator, assign.next), + .store_tag => |assign| try stack.append(self.store.allocator, assign.next), .set_local => |assign| try stack.append(self.store.allocator, assign.next), .debug => |debug_stmt| try stack.append(self.store.allocator, debug_stmt.next), .expect => |expect_stmt| try stack.append(self.store.allocator, expect_stmt.next), @@ -3529,6 +3601,8 @@ const Inserter = struct { .assign_list => |assign| try stack.append(self.store.allocator, assign.next), .assign_struct => |assign| try stack.append(self.store.allocator, assign.next), .assign_tag => |assign| try stack.append(self.store.allocator, assign.next), + .store_struct => |assign| try stack.append(self.store.allocator, assign.next), + .store_tag => |assign| try stack.append(self.store.allocator, assign.next), .set_local => |assign| try stack.append(self.store.allocator, assign.next), .debug => |debug_stmt| try stack.append(self.store.allocator, debug_stmt.next), .expect => |expect_stmt| try stack.append(self.store.allocator, expect_stmt.next), @@ -3771,6 +3845,16 @@ const Inserter = struct { setReadBeforeRebindDef(&graph, node_index, assign.target); try self.appendReadBeforeRebindSuccessor(&graph, &work, node_index, assign.next); }, + .store_struct => |assign| { + noteReadBeforeRebindLocal(&graph.nodes.items[node_index].reads, assign.dest); + self.noteReadBeforeRebindSpan(&graph.nodes.items[node_index].reads, assign.fields); + try self.appendReadBeforeRebindSuccessor(&graph, &work, node_index, assign.next); + }, + .store_tag => |assign| { + noteReadBeforeRebindLocal(&graph.nodes.items[node_index].reads, assign.dest); + if (assign.payload) |payload| noteReadBeforeRebindLocal(&graph.nodes.items[node_index].reads, payload); + try self.appendReadBeforeRebindSuccessor(&graph, &work, node_index, assign.next); + }, .set_local => |assign| { noteReadBeforeRebindLocal(&graph.nodes.items[node_index].reads, assign.value); setReadBeforeRebindDef(&graph, node_index, assign.target); @@ -4018,6 +4102,14 @@ const Inserter = struct { if (assign.target == needle) continue; try stack.append(self.store.allocator, assign.next); }, + .store_struct => |assign| { + if (assign.dest == needle or self.spanUsesLocal(assign.fields, needle)) return true; + try stack.append(self.store.allocator, assign.next); + }, + .store_tag => |assign| { + if (assign.dest == needle or (assign.payload != null and assign.payload.? == needle)) return true; + try stack.append(self.store.allocator, assign.next); + }, .set_local => |assign| { if (assign.value == needle) return true; if (assign.target == needle) continue; @@ -4224,6 +4316,14 @@ const Inserter = struct { if (assign.payload != null and needles.contains(assign.payload.?)) return true; try stack.append(self.store.allocator, assign.next); }, + .store_struct => |assign| { + if (needles.contains(assign.dest) or self.spanUsesAny(assign.fields, needles)) return true; + try stack.append(self.store.allocator, assign.next); + }, + .store_tag => |assign| { + if (needles.contains(assign.dest) or (assign.payload != null and needles.contains(assign.payload.?))) return true; + try stack.append(self.store.allocator, assign.next); + }, .set_local => |assign| { if (needles.contains(assign.value)) return true; try stack.append(self.store.allocator, assign.next); @@ -4356,6 +4456,9 @@ const Inserter = struct { fn retainLocalIfRcCount(self: *Inserter, local: LIR.LocalId, count: u16, next: LIR.CFStmtId) ResourceError!LIR.CFStmtId { if (count == 0) return next; if (!self.localContainsRefcounted(local)) return next; + if (count == 1) { + if (self.matchingImmediateDecref(local, next)) |after_decref| return after_decref; + } const rc = self.rcHelperForLocal(.incref, local); return try self.store.addCFStmt(.{ .incref = .{ .value = local, @@ -4366,6 +4469,13 @@ const Inserter = struct { } }); } + fn matchingImmediateDecref(self: *Inserter, local: LIR.LocalId, next: LIR.CFStmtId) ?LIR.CFStmtId { + return switch (self.store.getCFStmt(next)) { + .decref => |rc| if (rc.value == local) rc.next else null, + else => null, + }; + } + fn retainStrMatchSourceForCaptures(self: *Inserter, source: LIR.LocalId, steps: LIR.StrMatchStepSpan, next: LIR.CFStmtId) ResourceError!LIR.CFStmtId { var count: u16 = 0; const step_borrow = self.store.getStrMatchSteps(steps); @@ -4564,6 +4674,106 @@ fn refOpUsesAny(op: LIR.RefOp, needles: *const OwnedSet) bool { }; } +fn elideImmediateRcPairs(store: *LirStore, start: LIR.CFStmtId) ResourceError!LIR.CFStmtId { + const new_start = elideImmediateRcPairsAt(store, start); + var visited = std.AutoHashMap(LIR.CFStmtId, void).init(store.allocator); + defer visited.deinit(); + var stack = std.ArrayList(LIR.CFStmtId).empty; + defer stack.deinit(store.allocator); + try stack.append(store.allocator, new_start); + + while (stack.pop()) |current| { + if (visited.contains(current)) continue; + try visited.put(current, {}); + + const stmt = store.getCFStmtPtr(current); + switch (stmt.*) { + .switch_stmt => |*s| { + if (s.continuation) |continuation| { + s.continuation = elideImmediateRcPairsAt(store, continuation); + try stack.append(store.allocator, s.continuation.?); + } + s.default_branch = elideImmediateRcPairsAt(store, s.default_branch); + try stack.append(store.allocator, s.default_branch); + const branches = store.getCFSwitchBranchesMut(s.branches); + for (0..branches.len) |index| { + const branch = GuardedList.atPtr(branches, index); + branch.body = elideImmediateRcPairsAt(store, branch.body); + try stack.append(store.allocator, branch.body); + } + }, + .switch_initialized_payload => |*s| { + s.initialized_branch = elideImmediateRcPairsAt(store, s.initialized_branch); + s.uninitialized_branch = elideImmediateRcPairsAt(store, s.uninitialized_branch); + try stack.append(store.allocator, s.initialized_branch); + try stack.append(store.allocator, s.uninitialized_branch); + }, + .str_match => |*s| { + s.on_match = elideImmediateRcPairsAt(store, s.on_match); + s.on_miss = elideImmediateRcPairsAt(store, s.on_miss); + try stack.append(store.allocator, s.on_match); + try stack.append(store.allocator, s.on_miss); + }, + .str_match_set => |*s| { + const arms = store.getStrMatchArmsMut(s.arms); + for (0..arms.len) |index| { + const arm = GuardedList.atPtr(arms, index); + arm.on_match = elideImmediateRcPairsAt(store, arm.on_match); + try stack.append(store.allocator, arm.on_match); + } + s.on_miss = elideImmediateRcPairsAt(store, s.on_miss); + try stack.append(store.allocator, s.on_miss); + }, + .join => |*j| { + j.body = elideImmediateRcPairsAt(store, j.body); + j.remainder = elideImmediateRcPairsAt(store, j.remainder); + try stack.append(store.allocator, j.body); + try stack.append(store.allocator, j.remainder); + }, + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |*s| { + s.next = elideImmediateRcPairsAt(store, s.next); + try stack.append(store.allocator, s.next); + }, + .jump, .ret, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, + } + } + + return new_start; +} + +fn elideImmediateRcPairsAt(store: *LirStore, start: LIR.CFStmtId) LIR.CFStmtId { + var current = start; + var remaining = store.cfStmtCount() + 1; + while (remaining > 0) : (remaining -= 1) { + const retain = switch (store.getCFStmt(current)) { + .incref => |rc| rc, + else => return current, + }; + const release = switch (store.getCFStmt(retain.next)) { + .decref => |rc| rc, + else => return current, + }; + if (!rcRetainReleasePair(retain, release)) return current; + + if (retain.count == 1) { + current = release.next; + } else { + const stmt = store.getCFStmtPtr(current); + stmt.incref.count = retain.count - 1; + stmt.incref.next = release.next; + } + } + arcInvariant("ARC immediate RC elision hit a cyclic retain-release chain"); +} + +fn rcRetainReleasePair(retain: anytype, release: anytype) bool { + return retain.value == release.value and + retain.atomicity == release.atomicity and + retain.rc.op == .incref and + release.rc.op == .decref and + retain.rc.layout_idx == release.rc.layout_idx; +} + fn argMaskBit(index: usize) u64 { if (index >= 64) arcInvariant("ARC low-level runtime mutation argument mask exceeded 64 args"); return @as(u64, 1) << @as(u6, @intCast(index)); @@ -4578,6 +4788,48 @@ test "arc insertion boundary exists" { std.testing.refAllDecls(@This()); } +test "RC elision removes adjacent retain release pairs" { + var f = try ArcTest.init(testing.allocator); + defer f.deinit(); + const value = try f.local(.str); + const ret = try f.ret(value); + const release = try f.store.addCFStmt(.{ .decref = .{ + .value = value, + .rc = .{ .op = .decref, .layout_idx = .str }, + .next = ret, + } }); + const retain = try f.store.addCFStmt(.{ .incref = .{ + .value = value, + .rc = .{ .op = .incref, .layout_idx = .str }, + .next = release, + } }); + + try testing.expectEqual(ret, try elideImmediateRcPairs(&f.store, retain)); +} + +test "RC elision lowers adjacent multi retain count" { + var f = try ArcTest.init(testing.allocator); + defer f.deinit(); + const value = try f.local(.str); + const ret = try f.ret(value); + const release = try f.store.addCFStmt(.{ .decref = .{ + .value = value, + .rc = .{ .op = .decref, .layout_idx = .str }, + .next = ret, + } }); + const retain = try f.store.addCFStmt(.{ .incref = .{ + .value = value, + .rc = .{ .op = .incref, .layout_idx = .str }, + .count = 3, + .next = release, + } }); + + try testing.expectEqual(retain, try elideImmediateRcPairs(&f.store, retain)); + const stmt = f.store.getCFStmt(retain).incref; + try testing.expectEqual(@as(u16, 2), stmt.count); + try testing.expectEqual(ret, stmt.next); +} + const testing = std.testing; const ArcTest = struct { @@ -4987,7 +5239,7 @@ const ArcTest = struct { try stack.append(self.allocator, j.body); try stack.append(self.allocator, j.remainder); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try stack.append(self.allocator, s.next); }, .ret, .jump, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, @@ -5046,6 +5298,8 @@ const ArcTest = struct { .assign_list => |assign| cursor = assign.next, .assign_struct => |assign| cursor = assign.next, .assign_tag => |assign| cursor = assign.next, + .store_struct => |assign| cursor = assign.next, + .store_tag => |assign| cursor = assign.next, .set_local => |assign| cursor = assign.next, .debug => |debug_stmt| cursor = debug_stmt.next, .expect => |expect_stmt| cursor = expect_stmt.next, @@ -5098,6 +5352,8 @@ const ArcTest = struct { .assign_list => |assign| cursor = assign.next, .assign_struct => |assign| cursor = assign.next, .assign_tag => |assign| cursor = assign.next, + .store_struct => |assign| cursor = assign.next, + .store_tag => |assign| cursor = assign.next, .debug => |debug_stmt| cursor = debug_stmt.next, .expect => |expect_stmt| cursor = expect_stmt.next, .comptime_branch_taken => |marker| cursor = marker.next, @@ -7192,6 +7448,36 @@ test "RC interprocedural: borrowed return borrows the argument in the caller" { try f.expectRc(alias, 0, 0, 0); try f.expectRc(value, 0, 1, 0); } + +test "RC interprocedural: unused borrowed return elides retain release pair" { + var f = try ArcTest.init(testing.allocator); + defer f.deinit(); + + const id_param = try f.local(.str); + const id_ret = try f.ret(id_param); + const identity = try f.addProc(&.{id_param}, id_ret, .str); + + const value = try f.local(.str); + const unused_result = try f.local(.str); + const final_result = try f.local(.i64); + const ret = try f.ret(final_result); + const final_assign = try f.assignI64(final_result, 1, ret); + const call = try f.store.addCFStmt(.{ .assign_call = .{ + .target = unused_result, + .proc = identity, + .args = try f.span(&.{value}), + .next = final_assign, + } }); + const body = try f.assignStr(value, "unused-borrowed-return", call); + _ = try f.addProc(&.{}, body, .i64); + + try f.run(); + + try f.expectRc(id_param, 0, 0, 0); + try f.expectRc(unused_result, 0, 0, 0); + try f.expectRc(value, 0, 1, 0); +} + test "RC borrow survives the lender moving into an aggregate" { var f = try ArcTest.init(testing.allocator); defer f.deinit(); diff --git a/src/lir/arc_certify.zig b/src/lir/arc_certify.zig index d009335cd64..075af265720 100644 --- a/src/lir/arc_certify.zig +++ b/src/lir/arc_certify.zig @@ -307,7 +307,7 @@ fn certifyUniqueArgs( try stack.append(allocator, j.body); try stack.append(allocator, j.remainder); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try stack.append(allocator, s.next); }, .ret, .jump, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, @@ -455,7 +455,7 @@ fn writeFailureContext( walk.append(store.allocator, j.body) catch return; walk.append(store.allocator, j.remainder) catch return; }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .incref, .decref, .decref_if_initialized, .free => |s| { walk.append(store.allocator, s.next) catch return; }, } @@ -596,11 +596,13 @@ fn stmtMentionsLocal(store: *const LirStore, stmt: LIR.CFStmt, needle: LIR.Local .assign_literal => |a| a.target == needle, .assign_call => |a| a.target == needle or spanHasLocal(store, a.args, needle), .assign_call_erased => |a| a.target == needle or a.closure == needle or spanHasLocal(store, a.args, needle), - .assign_packed_erased_fn => |a| a.target == needle or (a.capture != null and a.capture.? == needle), + .assign_packed_erased_fn => |a| a.target == needle or (a.capture != null and a.capture.? == needle) or (a.reuse != null and a.reuse.? == needle), .assign_low_level => |a| a.target == needle or spanHasLocal(store, a.args, needle), .assign_list => |a| a.target == needle or spanHasLocal(store, a.elems, needle), .assign_struct => |a| a.target == needle or spanHasLocal(store, a.fields, needle), .assign_tag => |a| a.target == needle or (a.payload != null and a.payload.? == needle), + .store_struct => |a| a.dest == needle or spanHasLocal(store, a.fields, needle), + .store_tag => |a| a.dest == needle or (a.payload != null and a.payload.? == needle), .set_local => |a| a.target == needle or a.value == needle, .init_uninitialized => |a| a.target == needle, .debug => |d| d.message == needle, @@ -1012,23 +1014,29 @@ const Certifier = struct { /// lenders is live through either path: the holder keeps the moved unit's /// allocation alive, and live lenders keep the borrowed-from allocation /// alive. - fn valueIsLive(self: *const Certifier, state: *const State, value: ValueId) bool { - return self.valueIsLiveDepth(state, value, 0); + fn valueIsLive(self: *Certifier, state: *const State, value: ValueId) Allocator.Error!bool { + var seen = try std.bit_set.DynamicBitSetUnmanaged.initEmpty(self.allocator, self.values.items.len); + defer seen.deinit(self.allocator); + return self.valueIsLiveSeen(state, value, &seen); } - fn valueIsLiveDepth(self: *const Certifier, state: *const State, value: ValueId, depth: usize) bool { - if (depth > 64) return false; + fn valueIsLiveSeen(self: *Certifier, state: *const State, value: ValueId, seen: *std.bit_set.DynamicBitSetUnmanaged) Allocator.Error!bool { if (value >= self.values.items.len) return false; + const value_index: usize = @intCast(value); + if (seen.isSet(value_index)) return false; + seen.set(value_index); + defer seen.unset(value_index); + const info = self.values.items[value]; if (info.always_live) return true; if (state.balanceOf(value) > 0) return true; const holder = state.holderOf(value); - if (holder != no_value and self.valueIsLiveDepth(state, holder, depth + 1)) { + if (holder != no_value and try self.valueIsLiveSeen(state, holder, seen)) { return true; } if (info.lenders.len == 0) return false; for (info.lenders) |lender| { - if (!self.valueIsLiveDepth(state, lender, depth + 1)) return false; + if (!try self.valueIsLiveSeen(state, lender, seen)) return false; } return true; } @@ -1071,7 +1079,7 @@ const Certifier = struct { self.diag.context_proc = self.current_proc; return self.fail("use of unbound refcounted local {d}", .{@intFromEnum(local)}); } - if (!self.valueIsLive(state, value)) { + if (!try self.valueIsLive(state, value)) { self.diag.context_local = local; self.diag.context_proc = self.current_proc; self.describeValueChain(state, value); @@ -1168,12 +1176,12 @@ const Certifier = struct { } else { summary = .{ .class = .owned, .repr = repr, .balance = @intCast(units), .lender_repr = 0, .condition = no_dense, .condition_mask = 0 }; } - } else if (self.valueIsLive(state, value)) { + } else if (try self.valueIsLive(state, value)) { summary = .{ .class = .borrowed, .repr = repr, .balance = 0, - .lender_repr = self.liveAnchorRepr(state, value), + .lender_repr = try self.borrowSummaryAnchorRepr(state, value), .condition = no_dense, .condition_mask = 0, }; @@ -1187,15 +1195,51 @@ const Certifier = struct { } /// Returns the dense position anchoring the first unit-carrying (or - /// ABI-borrowed) value reached through lender/holder links, for stable - /// cross-path naming of where a borrow takes its liveness from. - fn liveAnchorRepr(self: *const Certifier, state: *const State, value: ValueId) u32 { - const anchor = self.liveAnchorValue(state, value); + /// ABI-borrowed) value reached through a borrowed value's lender chain, + /// falling back to holder links only when no complete lender chain is + /// live. Join summaries use this anchor for borrowed locals, so + /// preferring the original lender preserves source-borrow liveness across + /// join bodies that release a temporary retained holder before the next + /// borrow use. + fn borrowSummaryAnchorRepr(self: *Certifier, state: *const State, value: ValueId) Allocator.Error!u32 { + const anchor = try self.borrowSummaryAnchorValue(state, value); if (anchor == no_value) return 0; if (self.repr_scratch.get(anchor)) |repr| return repr; return self.denseOf(self.values.items[anchor].origin); } + fn borrowSummaryAnchorValue(self: *Certifier, state: *const State, value: ValueId) Allocator.Error!ValueId { + var seen = try std.bit_set.DynamicBitSetUnmanaged.initEmpty(self.allocator, self.values.items.len); + defer seen.deinit(self.allocator); + return self.borrowSummaryAnchorValueSeen(state, value, &seen); + } + + fn borrowSummaryAnchorValueSeen(self: *Certifier, state: *const State, value: ValueId, seen: *std.bit_set.DynamicBitSetUnmanaged) Allocator.Error!ValueId { + if (value >= self.values.items.len) return no_value; + const value_index: usize = @intCast(value); + if (seen.isSet(value_index)) return no_value; + seen.set(value_index); + defer seen.unset(value_index); + + const info = self.values.items[value]; + if (info.always_live or state.balanceOf(value) > 0) return value; + if (info.lenders.len != 0) { + var first_anchor: ValueId = no_value; + for (info.lenders) |lender| { + const anchor = try self.borrowSummaryAnchorValueSeen(state, lender, seen); + if (anchor == no_value) { + first_anchor = no_value; + break; + } + if (first_anchor == no_value) first_anchor = anchor; + } + if (first_anchor != no_value) return first_anchor; + } + const holder = state.holderOf(value); + if (holder != no_value) return try self.borrowSummaryAnchorValueSeen(state, holder, seen); + return no_value; + } + fn summaryDigest(cursor: LIR.CFStmtId, summary: []const LocalSummary) u64 { var hasher = std.hash.Wyhash.init(0x6172635f63657274); hasher.update(std.mem.asBytes(&cursor)); @@ -1571,6 +1615,7 @@ const Certifier = struct { .assign_packed_erased_fn => |assign| { try self.noteProcLocal(assign.target); if (assign.capture) |capture| try self.noteProcLocal(capture); + if (assign.reuse) |reuse| try self.noteProcLocal(reuse); try stack.append(self.allocator, assign.next); }, .assign_low_level => |assign| { @@ -1593,6 +1638,16 @@ const Certifier = struct { if (assign.payload) |payload| try self.noteProcLocal(payload); try stack.append(self.allocator, assign.next); }, + .store_struct => |assign| { + try self.noteProcLocal(assign.dest); + try self.noteProcLocalSpan(assign.fields); + try stack.append(self.allocator, assign.next); + }, + .store_tag => |assign| { + try self.noteProcLocal(assign.dest); + if (assign.payload) |payload| try self.noteProcLocal(payload); + try stack.append(self.allocator, assign.next); + }, .set_local => |assign| { try self.noteProcLocal(assign.target); try self.noteProcLocal(assign.value); @@ -1850,6 +1905,7 @@ const Certifier = struct { }, .assign_packed_erased_fn => |assign| { if (assign.capture) |capture| self.noteExposedReadLocal(&graph.nodes.items[node_index].reads, capture); + if (assign.reuse) |reuse| self.noteExposedReadLocal(&graph.nodes.items[node_index].reads, reuse); self.setReadBeforeRebindDef(&graph, node_index, assign.target); try self.appendReadBeforeRebindSuccessor(&graph, &work, node_index, assign.next); }, @@ -1873,6 +1929,16 @@ const Certifier = struct { self.setReadBeforeRebindDef(&graph, node_index, assign.target); try self.appendReadBeforeRebindSuccessor(&graph, &work, node_index, assign.next); }, + .store_struct => |assign| { + self.noteExposedReadLocal(&graph.nodes.items[node_index].reads, assign.dest); + self.noteExposedReadSpan(&graph.nodes.items[node_index].reads, assign.fields); + try self.appendReadBeforeRebindSuccessor(&graph, &work, node_index, assign.next); + }, + .store_tag => |assign| { + self.noteExposedReadLocal(&graph.nodes.items[node_index].reads, assign.dest); + if (assign.payload) |payload| self.noteExposedReadLocal(&graph.nodes.items[node_index].reads, payload); + try self.appendReadBeforeRebindSuccessor(&graph, &work, node_index, assign.next); + }, .set_local => |assign| { self.noteExposedReadLocal(&graph.nodes.items[node_index].reads, assign.value); self.setReadBeforeRebindDef(&graph, node_index, assign.target); @@ -2078,27 +2144,6 @@ const Certifier = struct { return relevant; } - /// Returns a unit-carrying or ABI-borrowed value that keeps this value - /// live, reached through holder or lender links, or `no_value` when no - /// chain is live. - fn liveAnchorValue(self: *const Certifier, state: *const State, value: ValueId) ValueId { - return self.liveAnchorValueDepth(state, value, 0); - } - - fn liveAnchorValueDepth(self: *const Certifier, state: *const State, value: ValueId, depth: usize) ValueId { - if (depth > 64) return no_value; - if (value >= self.values.items.len) return no_value; - const info = self.values.items[value]; - if (info.always_live or state.balanceOf(value) > 0) return value; - const holder = state.holderOf(value); - if (holder != no_value) { - const through_holder = self.liveAnchorValueDepth(state, holder, depth + 1); - if (through_holder != no_value) return through_holder; - } - if (info.lenders.len == 0) return no_value; - return self.liveAnchorValueDepth(state, info.lenders[0], depth + 1); - } - fn maybeUninitializedCondition(record: *const JoinRecord, store: *const LirStore, local: LIR.LocalId) ?PresenceCondition { const params = store.getLocalSpan(record.maybe_uninitialized_params); const conditions = store.getLocalSpan(record.maybe_uninitialized_conditions); @@ -2134,8 +2179,7 @@ const Certifier = struct { // Extend through borrow anchors: a relevant borrowed local keeps its // lender's carrier local live, so the carrier joins the agreement. var changed = true; - var rounds: usize = 0; - while (changed and rounds < 64) : (rounds += 1) { + while (changed) { changed = false; for (self.proc_locals.items) |local| { const local_index = @intFromEnum(local); @@ -2144,7 +2188,7 @@ const Certifier = struct { const value = state.valueOf(local); if (value == no_value) continue; if (state.balanceOf(value) > 0) continue; - const anchor = self.liveAnchorValue(state, value); + const anchor = try self.borrowSummaryAnchorValue(state, value); if (anchor == no_value) continue; // Find a carrier local for the anchor value. var carrier: u32 = no_dense; @@ -2206,12 +2250,12 @@ const Certifier = struct { } else { summary = .{ .class = .owned, .repr = repr, .balance = @intCast(units), .lender_repr = 0, .condition = no_dense, .condition_mask = 0 }; } - } else if (self.valueIsLive(state, value)) { + } else if (try self.valueIsLive(state, value)) { summary = .{ .class = .borrowed, .repr = repr, .balance = 0, - .lender_repr = self.liveAnchorRepr(state, value), + .lender_repr = try self.borrowSummaryAnchorRepr(state, value), .condition = no_dense, .condition_mask = 0, }; @@ -2400,13 +2444,25 @@ const Certifier = struct { try self.requireLive(&state, capture) else no_value; + const reuse_value = if (assign.reuse) |reuse| + try self.requireLive(&state, reuse) + else + no_value; if (self.isRc(assign.target)) { const target_value = try self.bindFresh(&state, assign.target, 1, &.{}); if (assign.capture != null) { try self.consumeIntoHolder(&state, capture_value, target_value); } + if (assign.reuse != null) { + try self.consumeUnit(&state, reuse_value, assign.reuse.?); + } } else if (assign.capture != null) { try self.consumeIntoHolder(&state, capture_value, no_value); + if (assign.reuse != null) { + try self.consumeUnit(&state, reuse_value, assign.reuse.?); + } + } else if (assign.reuse != null) { + try self.consumeUnit(&state, reuse_value, assign.reuse.?); } cursor = assign.next; }, @@ -2432,6 +2488,18 @@ const Certifier = struct { } cursor = assign.next; }, + .store_struct => |assign| { + try self.applyAggregateStore(&state, assign.dest, self.store.getLocalSpan(assign.fields)); + cursor = assign.next; + }, + .store_tag => |assign| { + if (assign.payload) |payload| { + try self.applyAggregateStore(&state, assign.dest, &[_]LIR.LocalId{payload}); + } else { + try self.applyAggregateStore(&state, assign.dest, &[_]LIR.LocalId{}); + } + cursor = assign.next; + }, .set_local => |assign| { if (assign.target != assign.value) { _ = try self.requireLive(&state, assign.value); @@ -2844,6 +2912,32 @@ const Certifier = struct { try self.consumeIntoHolder(state, value, target_value); } } + + fn applyAggregateStore( + self: *Certifier, + state: *State, + dest: LIR.LocalId, + operands: anytype, + ) CertifyError!void { + _ = try self.requireLive(state, dest); + + var operand_values_buffer: [64]ValueId = undefined; + for (0..GuardedList.borrowLen(operands)) |index| { + const operand = GuardedList.at(operands, index); + const value = try self.requireLive(state, operand); + if (index < operand_values_buffer.len) operand_values_buffer[index] = value; + } + + for (0..GuardedList.borrowLen(operands)) |index| { + const operand = GuardedList.at(operands, index); + if (!self.isRc(operand)) continue; + const value = if (index < operand_values_buffer.len) + operand_values_buffer[index] + else + state.valueOf(operand); + try self.consumeIntoHolder(state, value, no_value); + } + } }; fn refOpReadsLocal(op: LIR.RefOp, needle: LIR.LocalId) bool { @@ -3494,6 +3588,48 @@ test "certify accepts agreeing jumps through a join" { try f.certify(); } +test "certify preserves payload lender when retained holder crosses join" { + var f = try CertifyTest.init(testing.allocator); + defer f.deinit(); + const owner = try f.local(f.pair_str); + const field = try f.local(.str); + const retained_holder = try f.local(f.pair_str); + const holder_other = try f.local(.str); + const result = try f.local(.i64); + + const join_id = f.freshJoinPointId(); + const ret = try f.ret(result); + const result_assign = try f.assignI64(result, ret); + const use_field = try f.store.addCFStmt(.{ .expect = .{ + .condition = field, + .next = result_assign, + } }); + const release_holder = try f.decrefStmt(retained_holder, f.pair_str, use_field); + const jump = try f.store.addCFStmt(.{ .jump = .{ .target = join_id } }); + const join_stmt = try f.store.addCFStmt(.{ .join = .{ + .id = join_id, + .params = LIR.LocalSpan.empty(), + .body = release_holder, + .remainder = jump, + } }); + const holder_assign = try f.store.addCFStmt(.{ .assign_struct = .{ + .target = retained_holder, + .fields = try f.store.addLocalSpan(&.{ field, holder_other }), + .next = join_stmt, + } }); + const assign_holder_other = try f.assignStr(holder_other, holder_assign); + const retain_field = try f.increfStmt(field, .str, assign_holder_other); + const field_read = try f.store.addCFStmt(.{ .assign_ref = .{ + .target = field, + .op = .{ .field = .{ .source = owner, .field_idx = 0 } }, + .next = retain_field, + } }); + _ = try f.addProc(&.{owner}, field_read, .i64); + + const sigs = [_]arc_sig.RcSig{arc_sig.RcSig.all_owned.withBorrowedParam(0)}; + try f.certifyWith(.{ .sigs = &sigs }); +} + /// Builds the issue-9658 loop shape: `k` refcounted mutable locals whose /// alias groups merge and re-split across nested loop iterations. Branch i /// of the loop body retains x[i], releases x[i+1]'s old binding, re-aliases diff --git a/src/lir/arc_solve.zig b/src/lir/arc_solve.zig index fc0a2430588..a7909851643 100644 --- a/src/lir/arc_solve.zig +++ b/src/lir/arc_solve.zig @@ -579,10 +579,12 @@ fn resolveBindings(solver: *Solver, local_count: usize) SolveError!BindingResult cursor = solver.defs[cursor].borrow_capable; }; - const leader_once_bound = paramIsBorrowed(solver, chain_leader) or switch (solver.defs[chain_leader]) { - .fresh, .borrow_capable => true, - .none, .multi => false, - }; + const leader_once_bound = paramIsBorrowed(solver, chain_leader) or + leaderIsInitializedJoinParam(solver, chain_leader) or + switch (solver.defs[chain_leader]) { + .fresh, .borrow_capable => true, + .none, .multi => false, + }; const leader_is_anchor = solver.rc_local[chain_leader] and leader_once_bound and (!borrowed.isSet(chain_leader) or paramIsBorrowed(solver, chain_leader)); @@ -619,6 +621,18 @@ fn borrowQualifies(solver: *const Solver, index: u32) bool { }; } +/// A join parameter carries exactly one ownership unit into the join body at +/// every jump and holds it live across the whole body (released on exit paths, +/// transferred on back edges), so it anchors borrows just like an owned local +/// bound once: a borrow anchored on it is live for the whole body. Emission +/// keeps a join parameter's unit alive through the body already (its releases +/// belong to the join traversal, not to per-use death scans), so anchoring a +/// borrow here emits no retain/release pair. A maybe-uninitialized join +/// parameter may hold no unit on some entry, so it cannot anchor a borrow. +fn leaderIsInitializedJoinParam(solver: *const Solver, index: u32) bool { + return solver.join_param.isSet(index) and !solver.maybe_uninitialized_join_param.isSet(index); +} + /// Reports the borrowed-parameter lender mask when every `ret` in the body /// returns a borrow anchored on a borrowed parameter of this proc, ignoring /// the return occurrence's own demand. Returns null when any path returns an @@ -689,7 +703,7 @@ fn retLenders( try solver.stack.append(allocator, j.body); try solver.stack.append(allocator, j.remainder); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try solver.stack.append(allocator, s.next); }, .jump, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, @@ -758,7 +772,7 @@ fn retAllUnique( try solver.stack.append(allocator, j.body); try solver.stack.append(allocator, j.remainder); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try solver.stack.append(allocator, s.next); }, .jump, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, @@ -929,6 +943,7 @@ fn collectStmt( .assign_packed_erased_fn => |assign| { noteDef(solver.defs, assign.target, .fresh); if (assign.capture) |capture| noteDemand(solver, capture); + if (assign.reuse) |reuse| noteDemand(solver, reuse); try solver.stack.append(allocator, assign.next); }, .assign_low_level => |assign| { @@ -978,6 +993,20 @@ fn collectStmt( if (assign.payload) |payload| noteDemand(solver, payload); try solver.stack.append(allocator, assign.next); }, + .store_struct => |assign| { + noteDemand(solver, assign.dest); + const fields = store.getLocalSpan(assign.fields); + for (0..GuardedList.borrowLen(fields)) |index| { + const field = GuardedList.at(fields, index); + noteDemand(solver, field); + } + try solver.stack.append(allocator, assign.next); + }, + .store_tag => |assign| { + noteDemand(solver, assign.dest); + if (assign.payload) |payload| noteDemand(solver, payload); + try solver.stack.append(allocator, assign.next); + }, .set_local => |assign| { noteDef(solver.defs, assign.target, .fresh); if (assign.target != assign.value) noteDemand(solver, assign.value); @@ -1239,7 +1268,7 @@ pub fn computeVisibility( try stack.append(allocator, stmt.body); try stack.append(allocator, stmt.remainder); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |stmt| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |stmt| { try stack.append(allocator, stmt.next); }, .jump, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, @@ -1331,6 +1360,7 @@ pub fn computeVisibility( try addEdge(&edges, allocator, rc_local, @intFromEnum(assign.target), @intFromEnum(payload)); } }, + .store_struct, .store_tag => {}, .assign_packed_erased_fn => |assign| { if (assign.capture) |capture| { try addEdge(&edges, allocator, rc_local, @intFromEnum(assign.target), @intFromEnum(capture)); @@ -1475,7 +1505,7 @@ pub fn computeVisibility( pub const Uniqueness = struct { /// Bit set => every definition of the local binds a value whose /// outermost allocation originated at a unique birth: a fresh aggregate - /// or literal assignment, a low-level op whose `RcEffect` marks its + /// or non-static literal assignment, a low-level op whose `RcEffect` marks its /// result unique, a direct call whose callee's signature returns /// unique, or a pure same-value alias of a born-unique source. This is /// the origin property alone, independent of the holder accounting in @@ -1687,10 +1717,10 @@ pub fn computeUniqueness( .assign_literal => |assign| { marks.trackDef(&has_def, &multi_def, assign.target); switch (assign.value) { - // Static string and byte-list literals view backing whose - // count is the static sentinel, never 1, so it is not a - // unique birth and must never take an in-place path. - .str_literal, .bytes_literal => marks.destroy(&foreign_def, assign.target), + // Static-backed literals view backing whose count is the + // static sentinel, never 1, so they are not unique births + // and must never take in-place paths. + .str_literal, .static_data, .bytes_literal => marks.destroy(&foreign_def, assign.target), else => marks.noteBirth(&born, assign.target), } }, @@ -1730,8 +1760,9 @@ pub fn computeUniqueness( }, .assign_packed_erased_fn => |assign| { marks.trackDef(&has_def, &multi_def, assign.target); - marks.destroy(&foreign_def, assign.target); + marks.noteBirth(&born, assign.target); if (assign.capture) |capture| marks.destroy(&destroyed, capture); + if (assign.reuse) |reuse| marks.consume(&consumed_once, &destroyed, reuse); }, .str_match => |str_match| { marks.noteUse(&borrow_used, str_match.source); @@ -1817,6 +1848,16 @@ pub fn computeUniqueness( marks.noteBirth(&born, assign.target); if (assign.payload) |payload| marks.destroy(&destroyed, payload); }, + .store_struct => |assign| { + const fields = store.getLocalSpan(assign.fields); + for (0..GuardedList.borrowLen(fields)) |index| { + const field = GuardedList.at(fields, index); + marks.destroy(&destroyed, field); + } + }, + .store_tag => |assign| { + if (assign.payload) |payload| marks.destroy(&destroyed, payload); + }, .set_local => |assign| { marks.trackDef(&has_def, &multi_def, assign.target); marks.destroy(&foreign_def, assign.target); @@ -1959,7 +2000,7 @@ fn computeSccs(solver: *Solver) SolveError!void { try solver.stack.append(allocator, j.body); try solver.stack.append(allocator, j.remainder); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try solver.stack.append(allocator, s.next); }, .jump, .ret, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, diff --git a/src/lir/box_reuse.zig b/src/lir/box_reuse.zig new file mode 100644 index 00000000000..141259f3c8e --- /dev/null +++ b/src/lir/box_reuse.zig @@ -0,0 +1,1049 @@ +//! Rewrites direct allocation-replacement wrappers to reuse an existing +//! allocation when the LIR shape carries enough explicit information. +//! +//! This runs after SolvedLirLower/TRMC and before ARC insertion. It only +//! accepts an adjacent, straight-line shape: +//! +//! ```text +//! payload0 = box_unbox(boxed) +//! payload1 = call(payload0) +//! result = box_box(payload1) +//! ret result +//! ``` +//! +//! and rewrites it to: +//! +//! ```text +//! result = box_prepare_update(boxed) +//! payloadp = ptr_cast(result) +//! payload0 = ptr_load(payloadp) +//! payload1 = call(payload0) +//! _ = ptr_store(payloadp, payload1) +//! ret result +//! ``` +//! +//! It also accepts equivalent wrapper shapes when lowering routes the call +//! result through a one-parameter join before the final `box_box`, including the +//! platform-entrypoint form where the unbox and update call live in the join's +//! remainder. The join matchers only cross local aliases and zero-sized struct +//! statements, and validate the payload/box layouts before rewriting. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const core = @import("lir_core"); +const layout_mod = @import("layout"); + +const LIR = core.LIR; +const LirStore = core.LirStore; +const GuardedList = LirStore.GuardedList; +const LocalId = LIR.LocalId; +const CFStmtId = LIR.CFStmtId; +const LowLevelOp = LIR.LowLevel; + +/// Allocation failure raised while rewriting box update statements. +pub const ResourceError = Allocator.Error; + +/// Rewrite eligible box unwrap/update pairs to direct box reuse helper calls. +pub fn run(store: *LirStore, layouts: *layout_mod.Store) ResourceError!void { + const proc_count = store.procSpecCount(); + var proc_index: usize = 0; + while (proc_index < proc_count) : (proc_index += 1) { + const proc_id: LIR.LirProcSpecId = @enumFromInt(proc_index); + try transformProc(store, layouts, proc_id); + } +} + +fn transformProc(store: *LirStore, layouts: *layout_mod.Store, proc_id: LIR.LirProcSpecId) ResourceError!void { + const proc = store.getProcSpec(proc_id); + if (proc.body == null or proc.hosted != null or proc.abi != .roc) return; + + var transform = Transform{ + .store = store, + .layouts = layouts, + .proc_id = proc_id, + .new_locals = .empty, + }; + defer transform.new_locals.deinit(store.allocator); + + var current = proc.body.?; + while (true) { + _ = try transform.rewriteAt(current); + const next = transform.nextOf(current) orelse break; + current = next; + } + + if (transform.new_locals.items.len != 0) { + try transform.updateFrameLocals(); + } +} + +const Transform = struct { + store: *LirStore, + layouts: *layout_mod.Store, + proc_id: LIR.LirProcSpecId, + new_locals: std.ArrayList(LocalId), + + fn rewriteAt(self: *Transform, unbox_stmt_id: CFStmtId) ResourceError!bool { + if (try self.rewritePackedErasedAt(unbox_stmt_id)) return true; + if (try self.rewriteJoinBoxAt(unbox_stmt_id)) return true; + return try self.rewriteBoxAt(unbox_stmt_id); + } + + fn rewriteBoxAt(self: *Transform, unbox_stmt_id: CFStmtId) ResourceError!bool { + const unbox_stmt = switch (self.store.getCFStmt(unbox_stmt_id)) { + .assign_low_level => |s| s, + else => return false, + }; + if (unbox_stmt.op != .box_unbox) return false; + const unbox_args = self.store.getLocalSpan(unbox_stmt.args); + if (unbox_args.len != 1) return false; + const boxed = GuardedList.at(unbox_args, 0); + + if (try self.rewriteDirectBoxAt(unbox_stmt_id, unbox_stmt, boxed)) return true; + return try self.rewriteJoinedBoxAt(unbox_stmt_id, unbox_stmt, boxed); + } + + fn rewriteDirectBoxAt( + self: *Transform, + unbox_stmt_id: CFStmtId, + unbox_stmt: @FieldType(LIR.CFStmt, "assign_low_level"), + boxed: LocalId, + ) ResourceError!bool { + const call_stmt_id = unbox_stmt.next; + const call_stmt = switch (self.store.getCFStmt(call_stmt_id)) { + .assign_call => |s| s, + else => return false, + }; + const call_args = self.store.getLocalSpan(call_stmt.args); + if (call_args.len != 1 or GuardedList.at(call_args, 0) != unbox_stmt.target) return false; + + const payload_alias = self.forwardLocalAliasChain(call_stmt.target, call_stmt.next); + const payload_value = payload_alias.value; + const box_stmt_id = payload_alias.next; + const box_stmt = switch (self.store.getCFStmt(box_stmt_id)) { + .assign_low_level => |s| s, + else => return false, + }; + if (box_stmt.op != .box_box) return false; + const box_args = self.store.getLocalSpan(box_stmt.args); + if (box_args.len != 1 or GuardedList.at(box_args, 0) != payload_value) return false; + + const ret_stmt_id = box_stmt.next; + const ret_stmt = switch (self.store.getCFStmt(ret_stmt_id)) { + .ret => |s| s, + else => return false, + }; + if (ret_stmt.value != box_stmt.target) return false; + + const result_box = box_stmt.target; + if (boxed == result_box) return false; + + const box_layout = self.store.getLocal(boxed).layout_idx; + if (self.store.getLocal(result_box).layout_idx != box_layout) return false; + if (self.store.getProcSpec(self.proc_id).ret_layout != box_layout) return false; + + const box_layout_value = self.layouts.getLayout(box_layout); + if (box_layout_value.tag != .box) return false; + const payload_layout = box_layout_value.getIdx(); + if (self.store.getLocal(unbox_stmt.target).layout_idx != payload_layout) return false; + if (self.store.getLocal(payload_value).layout_idx != payload_layout) return false; + + const ptr_layout = try self.layouts.insertPtr(payload_layout); + const payload_ptr = try self.addLocal(ptr_layout); + const store_unit = try self.addLocal(.zst); + + const load_stmt_id = try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = unbox_stmt.target, + .op = .ptr_load, + .rc_effect = LowLevelOp.ptr_load.rcEffect(), + .args = try self.store.addLocalSpan(&.{payload_ptr}), + .next = call_stmt_id, + } }); + const cast_stmt_id = try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = payload_ptr, + .op = .ptr_cast, + .rc_effect = LowLevelOp.ptr_cast.rcEffect(), + .args = try self.store.addLocalSpan(&.{result_box}), + .next = load_stmt_id, + } }); + + self.store.getCFStmtPtr(unbox_stmt_id).* = .{ .assign_low_level = .{ + .target = result_box, + .op = .box_prepare_update, + .rc_effect = LowLevelOp.box_prepare_update.rcEffect(), + .args = try self.store.addLocalSpan(&.{boxed}), + .next = cast_stmt_id, + } }; + + self.store.getCFStmtPtr(box_stmt_id).* = .{ .assign_low_level = .{ + .target = store_unit, + .op = .ptr_store, + .rc_effect = LowLevelOp.ptr_store.rcEffect(), + .args = try self.store.addLocalSpan(&.{ payload_ptr, payload_value }), + .next = ret_stmt_id, + } }; + + return true; + } + + fn rewriteJoinedBoxAt( + self: *Transform, + unbox_stmt_id: CFStmtId, + unbox_stmt: @FieldType(LIR.CFStmt, "assign_low_level"), + boxed: LocalId, + ) ResourceError!bool { + const prelude = self.forwardThroughLocalAliasesAndZsts(unbox_stmt.target, unbox_stmt.next); + const join_stmt_id = prelude.next; + const join_stmt = switch (self.store.getCFStmt(join_stmt_id)) { + .join => |s| s, + else => return false, + }; + + const join_params = self.store.getLocalSpan(join_stmt.params); + if (join_params.len != 1) return false; + if (self.store.getLocalSpan(join_stmt.maybe_uninitialized_params).len != 0) return false; + if (self.store.getLocalSpan(join_stmt.maybe_uninitialized_conditions).len != 0) return false; + if (self.store.getU64Span(join_stmt.maybe_uninitialized_condition_masks).len != 0) return false; + const join_payload = GuardedList.at(join_params, 0); + + const body_alias = self.forwardLocalAliasChain(join_payload, join_stmt.body); + const payload_value = body_alias.value; + const box_stmt_id = body_alias.next; + const box_stmt = switch (self.store.getCFStmt(box_stmt_id)) { + .assign_low_level => |s| s, + else => return false, + }; + if (box_stmt.op != .box_box) return false; + const box_args = self.store.getLocalSpan(box_stmt.args); + if (box_args.len != 1 or GuardedList.at(box_args, 0) != payload_value) return false; + + const ret_stmt_id = box_stmt.next; + const ret_stmt = switch (self.store.getCFStmt(ret_stmt_id)) { + .ret => |s| s, + else => return false, + }; + if (ret_stmt.value != box_stmt.target) return false; + + const call_prelude = self.forwardThroughLocalAliasesAndZsts(prelude.value, join_stmt.remainder); + const call_stmt_id = call_prelude.next; + const call_stmt = switch (self.store.getCFStmt(call_stmt_id)) { + .assign_call => |s| s, + else => return false, + }; + if (call_stmt.target != join_payload) return false; + const call_args = self.store.getLocalSpan(call_stmt.args); + if (!spanHasLocal(call_args, call_prelude.value)) return false; + + const jump_stmt = switch (self.store.getCFStmt(call_stmt.next)) { + .jump => |s| s, + else => return false, + }; + if (jump_stmt.target != join_stmt.id) return false; + + const result_box = box_stmt.target; + if (boxed == result_box) return false; + + const box_layout = self.store.getLocal(boxed).layout_idx; + if (self.store.getLocal(result_box).layout_idx != box_layout) return false; + if (self.store.getProcSpec(self.proc_id).ret_layout != box_layout) return false; + + const box_layout_value = self.layouts.getLayout(box_layout); + if (box_layout_value.tag != .box) return false; + const payload_layout = box_layout_value.getIdx(); + if (self.store.getLocal(unbox_stmt.target).layout_idx != payload_layout) return false; + if (self.store.getLocal(prelude.value).layout_idx != payload_layout) return false; + if (self.store.getLocal(call_prelude.value).layout_idx != payload_layout) return false; + if (self.store.getLocal(join_payload).layout_idx != payload_layout) return false; + if (self.store.getLocal(payload_value).layout_idx != payload_layout) return false; + + const ptr_layout = try self.layouts.insertPtr(payload_layout); + const payload_ptr = try self.addLocal(ptr_layout); + const store_unit = try self.addLocal(.zst); + + const load_stmt_id = try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = unbox_stmt.target, + .op = .ptr_load, + .rc_effect = LowLevelOp.ptr_load.rcEffect(), + .args = try self.store.addLocalSpan(&.{payload_ptr}), + .next = unbox_stmt.next, + } }); + const cast_stmt_id = try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = payload_ptr, + .op = .ptr_cast, + .rc_effect = LowLevelOp.ptr_cast.rcEffect(), + .args = try self.store.addLocalSpan(&.{result_box}), + .next = load_stmt_id, + } }); + + self.store.getCFStmtPtr(unbox_stmt_id).* = .{ .assign_low_level = .{ + .target = result_box, + .op = .box_prepare_update, + .rc_effect = LowLevelOp.box_prepare_update.rcEffect(), + .args = try self.store.addLocalSpan(&.{boxed}), + .next = cast_stmt_id, + } }; + + self.store.getCFStmtPtr(box_stmt_id).* = .{ .assign_low_level = .{ + .target = store_unit, + .op = .ptr_store, + .rc_effect = LowLevelOp.ptr_store.rcEffect(), + .args = try self.store.addLocalSpan(&.{ payload_ptr, payload_value }), + .next = ret_stmt_id, + } }; + + return true; + } + + fn rewriteJoinBoxAt(self: *Transform, join_stmt_id: CFStmtId) ResourceError!bool { + const join_stmt = switch (self.store.getCFStmt(join_stmt_id)) { + .join => |s| s, + else => return false, + }; + + const join_params = self.store.getLocalSpan(join_stmt.params); + if (join_params.len != 1) return false; + if (self.store.getLocalSpan(join_stmt.maybe_uninitialized_params).len != 0) return false; + if (self.store.getLocalSpan(join_stmt.maybe_uninitialized_conditions).len != 0) return false; + if (self.store.getU64Span(join_stmt.maybe_uninitialized_condition_masks).len != 0) return false; + const join_payload = GuardedList.at(join_params, 0); + + const body_alias = self.forwardLocalAliasChain(join_payload, join_stmt.body); + const payload_value = body_alias.value; + const box_stmt_id = body_alias.next; + const box_stmt = switch (self.store.getCFStmt(box_stmt_id)) { + .assign_low_level => |s| s, + else => return false, + }; + if (box_stmt.op != .box_box) return false; + const box_args = self.store.getLocalSpan(box_stmt.args); + if (box_args.len != 1 or GuardedList.at(box_args, 0) != payload_value) return false; + + const ret_stmt_id = box_stmt.next; + const ret_stmt = switch (self.store.getCFStmt(ret_stmt_id)) { + .ret => |s| s, + else => return false, + }; + if (ret_stmt.value != box_stmt.target) return false; + + const unbox_stmt_id = self.skipLocalAliasesAndZsts(join_stmt.remainder); + const unbox_stmt = switch (self.store.getCFStmt(unbox_stmt_id)) { + .assign_low_level => |s| s, + else => return false, + }; + if (unbox_stmt.op != .box_unbox) return false; + const unbox_args = self.store.getLocalSpan(unbox_stmt.args); + if (unbox_args.len != 1) return false; + const boxed = GuardedList.at(unbox_args, 0); + + const call_prelude = self.forwardThroughLocalAliasesAndZsts(unbox_stmt.target, unbox_stmt.next); + const call_stmt_id = call_prelude.next; + const call_stmt = switch (self.store.getCFStmt(call_stmt_id)) { + .assign_call => |s| s, + else => return false, + }; + if (call_stmt.target != join_payload) return false; + const call_args = self.store.getLocalSpan(call_stmt.args); + if (!spanHasLocal(call_args, call_prelude.value)) return false; + + const jump_stmt = switch (self.store.getCFStmt(call_stmt.next)) { + .jump => |s| s, + else => return false, + }; + if (jump_stmt.target != join_stmt.id) return false; + if (try self.jumpCountToJoin(join_stmt.id) != 1) return false; + + const result_box = box_stmt.target; + if (boxed == result_box) return false; + + const box_layout = self.store.getLocal(boxed).layout_idx; + if (self.store.getLocal(result_box).layout_idx != box_layout) return false; + if (self.store.getProcSpec(self.proc_id).ret_layout != box_layout) return false; + + const box_layout_value = self.layouts.getLayout(box_layout); + if (box_layout_value.tag != .box) return false; + const payload_layout = box_layout_value.getIdx(); + if (self.store.getLocal(unbox_stmt.target).layout_idx != payload_layout) return false; + if (self.store.getLocal(call_prelude.value).layout_idx != payload_layout) return false; + if (self.store.getLocal(join_payload).layout_idx != payload_layout) return false; + if (self.store.getLocal(payload_value).layout_idx != payload_layout) return false; + + const ptr_layout = try self.layouts.insertPtr(payload_layout); + const payload_ptr = try self.addLocal(ptr_layout); + const store_unit = try self.addLocal(.zst); + + const load_stmt_id = try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = unbox_stmt.target, + .op = .ptr_load, + .rc_effect = LowLevelOp.ptr_load.rcEffect(), + .args = try self.store.addLocalSpan(&.{payload_ptr}), + .next = unbox_stmt.next, + } }); + const cast_stmt_id = try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = payload_ptr, + .op = .ptr_cast, + .rc_effect = LowLevelOp.ptr_cast.rcEffect(), + .args = try self.store.addLocalSpan(&.{result_box}), + .next = load_stmt_id, + } }); + + self.store.getCFStmtPtr(unbox_stmt_id).* = .{ .assign_low_level = .{ + .target = result_box, + .op = .box_prepare_update, + .rc_effect = LowLevelOp.box_prepare_update.rcEffect(), + .args = try self.store.addLocalSpan(&.{boxed}), + .next = cast_stmt_id, + } }; + + self.store.getCFStmtPtr(box_stmt_id).* = .{ .assign_low_level = .{ + .target = store_unit, + .op = .ptr_store, + .rc_effect = LowLevelOp.ptr_store.rcEffect(), + .args = try self.store.addLocalSpan(&.{ payload_ptr, payload_value }), + .next = ret_stmt_id, + } }; + + self.store.getCFStmtPtr(join_stmt_id).* = .{ .join = .{ + .id = join_stmt.id, + .params = try self.store.addLocalSpan(&.{ join_payload, result_box, payload_ptr }), + .maybe_uninitialized_params = join_stmt.maybe_uninitialized_params, + .maybe_uninitialized_conditions = join_stmt.maybe_uninitialized_conditions, + .maybe_uninitialized_condition_masks = join_stmt.maybe_uninitialized_condition_masks, + .body = join_stmt.body, + .remainder = join_stmt.remainder, + } }; + + return true; + } + + fn rewritePackedErasedAt(self: *Transform, old_stmt_id: CFStmtId) ResourceError!bool { + const old_stmt = switch (self.store.getCFStmt(old_stmt_id)) { + .assign_packed_erased_fn => |s| s, + else => return false, + }; + if (old_stmt.reuse != null) return false; + + const new_stmt_id = old_stmt.next; + const new_stmt = switch (self.store.getCFStmt(new_stmt_id)) { + .assign_packed_erased_fn => |s| s, + else => return false, + }; + if (new_stmt.reuse != null) return false; + if (old_stmt.target == new_stmt.target) return false; + if (new_stmt.capture != null and new_stmt.capture.? == old_stmt.target) return false; + + const ret_stmt = switch (self.store.getCFStmt(new_stmt.next)) { + .ret => |s| s, + else => return false, + }; + if (ret_stmt.value != new_stmt.target) return false; + + const erased_layout = self.store.getLocal(old_stmt.target).layout_idx; + if (self.store.getLocal(new_stmt.target).layout_idx != erased_layout) return false; + if (self.store.getProcSpec(self.proc_id).ret_layout != erased_layout) return false; + if (self.layouts.getLayout(erased_layout).tag != .erased_callable) return false; + + if (!self.samePackedErasedPayloadShape(old_stmt.capture_layout, new_stmt.capture_layout)) return false; + + self.store.getCFStmtPtr(new_stmt_id).* = .{ .assign_packed_erased_fn = .{ + .target = new_stmt.target, + .proc = new_stmt.proc, + .capture = new_stmt.capture, + .capture_layout = new_stmt.capture_layout, + .on_drop = new_stmt.on_drop, + .reuse = old_stmt.target, + .next = new_stmt.next, + } }; + + return true; + } + + fn samePackedErasedPayloadShape(self: *const Transform, old_layout: ?layout_mod.Idx, new_layout: ?layout_mod.Idx) bool { + if (old_layout == null or new_layout == null) return old_layout == null and new_layout == null; + const old_size_align = self.layouts.layoutSizeAlign(self.layouts.getLayout(old_layout.?)); + const new_size_align = self.layouts.layoutSizeAlign(self.layouts.getLayout(new_layout.?)); + return old_size_align.size == new_size_align.size and + old_size_align.alignment.toByteUnits() == new_size_align.alignment.toByteUnits(); + } + + const ForwardedAlias = struct { + value: LocalId, + next: CFStmtId, + }; + + fn forwardLocalAliasChain(self: *const Transform, source: LocalId, first_stmt: CFStmtId) ForwardedAlias { + var value = source; + var current = first_stmt; + while (true) { + const stmt = switch (self.store.getCFStmt(current)) { + .assign_ref => |s| s, + else => return .{ .value = value, .next = current }, + }; + switch (stmt.op) { + .local => |local| if (local == value and self.store.getLocal(stmt.target).layout_idx == self.store.getLocal(value).layout_idx) { + value = stmt.target; + current = stmt.next; + continue; + }, + else => {}, + } + return .{ .value = value, .next = current }; + } + } + + fn forwardThroughLocalAliasesAndZsts(self: *const Transform, source: LocalId, first_stmt: CFStmtId) ForwardedAlias { + var value = source; + var current = first_stmt; + while (true) { + switch (self.store.getCFStmt(current)) { + .assign_ref => |stmt| { + switch (stmt.op) { + .local => |local| { + if (local == value and self.store.getLocal(stmt.target).layout_idx == self.store.getLocal(value).layout_idx) { + value = stmt.target; + } + current = stmt.next; + continue; + }, + else => return .{ .value = value, .next = current }, + } + }, + .assign_struct => |stmt| { + if (self.store.getLocal(stmt.target).layout_idx != .zst) return .{ .value = value, .next = current }; + if (self.store.getLocalSpan(stmt.fields).len != 0) return .{ .value = value, .next = current }; + current = stmt.next; + continue; + }, + else => return .{ .value = value, .next = current }, + } + } + } + + fn skipLocalAliasesAndZsts(self: *const Transform, first_stmt: CFStmtId) CFStmtId { + var current = first_stmt; + while (true) { + switch (self.store.getCFStmt(current)) { + .assign_ref => |stmt| switch (stmt.op) { + .local => current = stmt.next, + else => return current, + }, + .assign_struct => |stmt| { + if (self.store.getLocal(stmt.target).layout_idx != .zst) return current; + if (self.store.getLocalSpan(stmt.fields).len != 0) return current; + current = stmt.next; + }, + else => return current, + } + } + } + + fn jumpCountToJoin(self: *Transform, join_id: LIR.JoinPointId) ResourceError!usize { + const proc = self.store.getProcSpec(self.proc_id); + const body = proc.body orelse return 0; + + var work = std.ArrayList(CFStmtId).empty; + defer work.deinit(self.store.allocator); + var visited = std.AutoHashMap(CFStmtId, void).init(self.store.allocator); + defer visited.deinit(); + + var count: usize = 0; + try work.append(self.store.allocator, body); + while (work.pop()) |stmt_id| { + const entry = try visited.getOrPut(stmt_id); + if (entry.found_existing) continue; + + switch (self.store.getCFStmt(stmt_id)) { + .jump => |stmt| { + if (stmt.target == join_id) count += 1; + }, + else => try self.appendSuccessors(&work, stmt_id), + } + } + + return count; + } + + fn appendSuccessors(self: *Transform, work: *std.ArrayList(CFStmtId), stmt_id: CFStmtId) ResourceError!void { + switch (self.store.getCFStmt(stmt_id)) { + inline .assign_ref, + .assign_literal, + .init_uninitialized, + .assign_call, + .assign_call_erased, + .assign_packed_erased_fn, + .assign_low_level, + .assign_list, + .assign_struct, + .assign_tag, + .store_struct, + .store_tag, + .set_local, + .debug, + .expect, + .comptime_branch_taken, + .incref, + .decref, + .decref_if_initialized, + .free, + => |stmt| try work.append(self.store.allocator, stmt.next), + .switch_stmt => |stmt| { + if (stmt.continuation) |continuation| try work.append(self.store.allocator, continuation); + const cases = self.store.getCFSwitchBranches(stmt.branches); + for (0..cases.len) |case_index| { + try work.append(self.store.allocator, GuardedList.at(cases, case_index).body); + } + try work.append(self.store.allocator, stmt.default_branch); + }, + .switch_initialized_payload => |stmt| { + try work.append(self.store.allocator, stmt.initialized_branch); + try work.append(self.store.allocator, stmt.uninitialized_branch); + }, + .str_match => |stmt| { + try work.append(self.store.allocator, stmt.on_match); + try work.append(self.store.allocator, stmt.on_miss); + }, + .str_match_set => |stmt| { + const arms = self.store.getStrMatchArms(stmt.arms); + for (0..arms.len) |arm_index| { + try work.append(self.store.allocator, GuardedList.at(arms, arm_index).on_match); + } + try work.append(self.store.allocator, stmt.on_miss); + }, + .join => |stmt| { + try work.append(self.store.allocator, stmt.body); + try work.append(self.store.allocator, stmt.remainder); + }, + .runtime_error, + .comptime_exhaustiveness_failed, + .loop_continue, + .loop_break, + .jump, + .ret, + .crash, + .expect_err, + => {}, + } + } + + fn addLocal(self: *Transform, layout_idx: layout_mod.Idx) ResourceError!LocalId { + const local = try self.store.addLocal(.{ .layout_idx = layout_idx }); + try self.new_locals.append(self.store.allocator, local); + return local; + } + + fn updateFrameLocals(self: *Transform) ResourceError!void { + const proc = self.store.getProcSpec(self.proc_id); + const old = self.store.getLocalSpan(proc.frame_locals); + var merged = try std.ArrayList(LocalId).initCapacity(self.store.allocator, old.len + self.new_locals.items.len); + defer merged.deinit(self.store.allocator); + for (0..old.len) |index| merged.appendAssumeCapacity(GuardedList.at(old, index)); + merged.appendSliceAssumeCapacity(self.new_locals.items); + std.mem.sort(LocalId, merged.items, {}, localIdLessThan); + + var unique_len: usize = 0; + for (merged.items, 0..) |local, idx| { + if (idx > 0 and merged.items[unique_len - 1] == local) continue; + merged.items[unique_len] = local; + unique_len += 1; + } + + const frame_locals = try self.store.addLocalSpan(merged.items[0..unique_len]); + self.store.getProcSpecPtr(self.proc_id).frame_locals = frame_locals; + } + + fn localIdLessThan(_: void, a: LocalId, b: LocalId) bool { + return @intFromEnum(a) < @intFromEnum(b); + } + + fn nextOf(self: *const Transform, stmt_id: CFStmtId) ?CFStmtId { + return switch (self.store.getCFStmt(stmt_id)) { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| s.next, + else => null, + }; + } +}; + +fn spanHasLocal(locals: anytype, needle: LocalId) bool { + for (0..locals.len) |index| { + if (GuardedList.at(locals, index) == needle) return true; + } + return false; +} + +fn testLocal(store: *LirStore, layout_idx: layout_mod.Idx) ResourceError!LocalId { + return try store.addLocal(.{ .layout_idx = layout_idx }); +} + +fn testLowLevel(store: *LirStore, target: LocalId, op: LowLevelOp, args: []const LocalId, next: CFStmtId) ResourceError!CFStmtId { + return try store.addCFStmt(.{ .assign_low_level = .{ + .target = target, + .op = op, + .rc_effect = op.rcEffect(), + .args = try store.addLocalSpan(args), + .next = next, + } }); +} + +fn testLocalRef(store: *LirStore, target: LocalId, source: LocalId, next: CFStmtId) ResourceError!CFStmtId { + return try store.addCFStmt(.{ .assign_ref = .{ + .target = target, + .op = .{ .local = source }, + .next = next, + } }); +} + +fn testZst(store: *LirStore, target: LocalId, next: CFStmtId) ResourceError!CFStmtId { + return try store.addCFStmt(.{ .assign_struct = .{ + .target = target, + .fields = try store.addLocalSpan(&.{}), + .next = next, + } }); +} + +fn testFreshJoinPointId(next_join_point: *u32) LIR.JoinPointId { + const id: LIR.JoinPointId = @enumFromInt(next_join_point.*); + next_join_point.* += 1; + return id; +} + +test "box reuse rewrites the direct unbox call rebox return chain" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout_mod.Store.init(allocator, @import("base").target.TargetUsize.native); + defer layouts.deinit(); + + const box_u64 = try layouts.insertBox(.u64); + + const callee_arg = try testLocal(&store, .u64); + const callee = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{callee_arg}), + .frame_locals = try store.addLocalSpan(&.{callee_arg}), + .ret_layout = .u64, + }); + + const boxed_arg = try testLocal(&store, box_u64); + const old_payload = try testLocal(&store, .u64); + const new_payload = try testLocal(&store, .u64); + const result_box = try testLocal(&store, box_u64); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = result_box } }); + const rebox = try testLowLevel(&store, result_box, .box_box, &.{new_payload}, ret); + const call = try store.addCFStmt(.{ .assign_call = .{ + .target = new_payload, + .proc = callee, + .args = try store.addLocalSpan(&.{old_payload}), + .next = rebox, + } }); + const unbox = try testLowLevel(&store, old_payload, .box_unbox, &.{boxed_arg}, call); + const caller = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{boxed_arg}), + .frame_locals = try store.addLocalSpan(&.{ boxed_arg, old_payload, new_payload, result_box }), + .body = unbox, + .ret_layout = box_u64, + }); + + try run(&store, &layouts); + + const prepare = store.getCFStmt(unbox).assign_low_level; + try std.testing.expectEqual(LowLevelOp.box_prepare_update, prepare.op); + try std.testing.expectEqual(result_box, prepare.target); + try std.testing.expectEqual(boxed_arg, GuardedList.at(store.getLocalSpan(prepare.args), 0)); + + const cast = store.getCFStmt(prepare.next).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_cast, cast.op); + const payload_ptr = cast.target; + try std.testing.expectEqual(result_box, GuardedList.at(store.getLocalSpan(cast.args), 0)); + + const load = store.getCFStmt(cast.next).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_load, load.op); + try std.testing.expectEqual(old_payload, load.target); + try std.testing.expectEqual(payload_ptr, GuardedList.at(store.getLocalSpan(load.args), 0)); + try std.testing.expectEqual(call, load.next); + + const store_payload = store.getCFStmt(rebox).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_store, store_payload.op); + const store_args = store.getLocalSpan(store_payload.args); + try std.testing.expectEqual(payload_ptr, GuardedList.at(store_args, 0)); + try std.testing.expectEqual(new_payload, GuardedList.at(store_args, 1)); + try std.testing.expectEqual(ret, store_payload.next); + + const frame_locals = store.getLocalSpan(store.getProcSpec(caller).frame_locals); + try std.testing.expect(frame_locals.len >= 6); +} + +test "box reuse rewrites joined update wrappers" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout_mod.Store.init(allocator, @import("base").target.TargetUsize.native); + defer layouts.deinit(); + + const box_u64 = try layouts.insertBox(.u64); + + const callee_old = try testLocal(&store, .u64); + const callee_delta = try testLocal(&store, .u64); + const callee = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{ callee_old, callee_delta }), + .frame_locals = try store.addLocalSpan(&.{ callee_old, callee_delta }), + .ret_layout = .u64, + }); + + const boxed_arg = try testLocal(&store, box_u64); + const delta_arg = try testLocal(&store, .u64); + const old_payload = try testLocal(&store, .u64); + const old_payload_alias = try testLocal(&store, .u64); + const call_payload_alias = try testLocal(&store, .u64); + const delta_alias = try testLocal(&store, .u64); + const join_payload = try testLocal(&store, .u64); + const body_payload_alias = try testLocal(&store, .u64); + const result_box = try testLocal(&store, box_u64); + const prelude_zst = try testLocal(&store, .zst); + const remainder_zst = try testLocal(&store, .zst); + + var next_join_point: u32 = 0; + const join_id = testFreshJoinPointId(&next_join_point); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = result_box } }); + const rebox = try testLowLevel(&store, result_box, .box_box, &.{body_payload_alias}, ret); + const body_alias = try testLocalRef(&store, body_payload_alias, join_payload, rebox); + + const jump = try store.addCFStmt(.{ .jump = .{ .target = join_id } }); + const call = try store.addCFStmt(.{ .assign_call = .{ + .target = join_payload, + .proc = callee, + .args = try store.addLocalSpan(&.{ call_payload_alias, delta_alias }), + .next = jump, + } }); + const delta_ref = try testLocalRef(&store, delta_alias, delta_arg, call); + const call_payload_ref = try testLocalRef(&store, call_payload_alias, old_payload_alias, delta_ref); + const remainder_zst_stmt = try testZst(&store, remainder_zst, call_payload_ref); + + const join = try store.addCFStmt(.{ .join = .{ + .id = join_id, + .params = try store.addLocalSpan(&.{join_payload}), + .body = body_alias, + .remainder = remainder_zst_stmt, + } }); + const prelude_zst_stmt = try testZst(&store, prelude_zst, join); + const old_payload_ref = try testLocalRef(&store, old_payload_alias, old_payload, prelude_zst_stmt); + const unbox = try testLowLevel(&store, old_payload, .box_unbox, &.{boxed_arg}, old_payload_ref); + const caller = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{ boxed_arg, delta_arg }), + .frame_locals = try store.addLocalSpan(&.{ + boxed_arg, + delta_arg, + old_payload, + old_payload_alias, + call_payload_alias, + delta_alias, + join_payload, + body_payload_alias, + result_box, + prelude_zst, + remainder_zst, + }), + .body = unbox, + .ret_layout = box_u64, + }); + + try run(&store, &layouts); + + const prepare = store.getCFStmt(unbox).assign_low_level; + try std.testing.expectEqual(LowLevelOp.box_prepare_update, prepare.op); + try std.testing.expectEqual(result_box, prepare.target); + try std.testing.expectEqual(boxed_arg, GuardedList.at(store.getLocalSpan(prepare.args), 0)); + + const cast = store.getCFStmt(prepare.next).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_cast, cast.op); + const payload_ptr = cast.target; + try std.testing.expectEqual(result_box, GuardedList.at(store.getLocalSpan(cast.args), 0)); + + const load = store.getCFStmt(cast.next).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_load, load.op); + try std.testing.expectEqual(old_payload, load.target); + try std.testing.expectEqual(payload_ptr, GuardedList.at(store.getLocalSpan(load.args), 0)); + try std.testing.expectEqual(old_payload_ref, load.next); + + const store_payload = store.getCFStmt(rebox).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_store, store_payload.op); + const store_args = store.getLocalSpan(store_payload.args); + try std.testing.expectEqual(payload_ptr, GuardedList.at(store_args, 0)); + try std.testing.expectEqual(body_payload_alias, GuardedList.at(store_args, 1)); + try std.testing.expectEqual(ret, store_payload.next); + + const frame_locals = store.getLocalSpan(store.getProcSpec(caller).frame_locals); + try std.testing.expect(frame_locals.len >= 13); +} + +test "box reuse rewrites platform-style join remainder update wrappers" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout_mod.Store.init(allocator, @import("base").target.TargetUsize.native); + defer layouts.deinit(); + + const box_u64 = try layouts.insertBox(.u64); + + const callee_old = try testLocal(&store, .u64); + const callee = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{callee_old}), + .frame_locals = try store.addLocalSpan(&.{callee_old}), + .ret_layout = .u64, + }); + + const boxed_arg = try testLocal(&store, box_u64); + const boxed_alias_a = try testLocal(&store, box_u64); + const boxed_alias_b = try testLocal(&store, box_u64); + const old_payload = try testLocal(&store, .u64); + const join_payload = try testLocal(&store, .u64); + const body_payload_alias = try testLocal(&store, .u64); + const result_box = try testLocal(&store, box_u64); + const proc_zst = try testLocal(&store, .zst); + const remainder_zst = try testLocal(&store, .zst); + + var next_join_point: u32 = 0; + const join_id = testFreshJoinPointId(&next_join_point); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = result_box } }); + const rebox = try testLowLevel(&store, result_box, .box_box, &.{body_payload_alias}, ret); + const body_alias = try testLocalRef(&store, body_payload_alias, join_payload, rebox); + + const jump = try store.addCFStmt(.{ .jump = .{ .target = join_id } }); + const call = try store.addCFStmt(.{ .assign_call = .{ + .target = join_payload, + .proc = callee, + .args = try store.addLocalSpan(&.{old_payload}), + .next = jump, + } }); + const unbox = try testLowLevel(&store, old_payload, .box_unbox, &.{boxed_alias_b}, call); + const boxed_ref_b = try testLocalRef(&store, boxed_alias_b, boxed_alias_a, unbox); + const boxed_ref_a = try testLocalRef(&store, boxed_alias_a, boxed_arg, boxed_ref_b); + const remainder_zst_stmt = try testZst(&store, remainder_zst, boxed_ref_a); + + const join = try store.addCFStmt(.{ .join = .{ + .id = join_id, + .params = try store.addLocalSpan(&.{join_payload}), + .body = body_alias, + .remainder = remainder_zst_stmt, + } }); + const proc_zst_stmt = try testZst(&store, proc_zst, join); + const caller = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{boxed_arg}), + .frame_locals = try store.addLocalSpan(&.{ + boxed_arg, + boxed_alias_a, + boxed_alias_b, + old_payload, + join_payload, + body_payload_alias, + result_box, + proc_zst, + remainder_zst, + }), + .body = proc_zst_stmt, + .ret_layout = box_u64, + }); + + try run(&store, &layouts); + + const prepare = store.getCFStmt(unbox).assign_low_level; + try std.testing.expectEqual(LowLevelOp.box_prepare_update, prepare.op); + try std.testing.expectEqual(result_box, prepare.target); + try std.testing.expectEqual(boxed_alias_b, GuardedList.at(store.getLocalSpan(prepare.args), 0)); + + const cast = store.getCFStmt(prepare.next).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_cast, cast.op); + const payload_ptr = cast.target; + try std.testing.expectEqual(result_box, GuardedList.at(store.getLocalSpan(cast.args), 0)); + + const load = store.getCFStmt(cast.next).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_load, load.op); + try std.testing.expectEqual(old_payload, load.target); + try std.testing.expectEqual(payload_ptr, GuardedList.at(store.getLocalSpan(load.args), 0)); + try std.testing.expectEqual(call, load.next); + + const rewritten_join = store.getCFStmt(join).join; + const rewritten_params = store.getLocalSpan(rewritten_join.params); + try std.testing.expectEqual(@as(usize, 3), rewritten_params.len); + try std.testing.expectEqual(join_payload, GuardedList.at(rewritten_params, 0)); + try std.testing.expectEqual(result_box, GuardedList.at(rewritten_params, 1)); + try std.testing.expectEqual(payload_ptr, GuardedList.at(rewritten_params, 2)); + + const store_payload = store.getCFStmt(rebox).assign_low_level; + try std.testing.expectEqual(LowLevelOp.ptr_store, store_payload.op); + const store_args = store.getLocalSpan(store_payload.args); + try std.testing.expectEqual(payload_ptr, GuardedList.at(store_args, 0)); + try std.testing.expectEqual(body_payload_alias, GuardedList.at(store_args, 1)); + try std.testing.expectEqual(ret, store_payload.next); + + const frame_locals = store.getLocalSpan(store.getProcSpec(caller).frame_locals); + try std.testing.expect(frame_locals.len >= 11); +} + +test "erased callable reuse rewrites adjacent same-shape repack" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout_mod.Store.init(allocator, @import("base").target.TargetUsize.native); + defer layouts.deinit(); + + const erased_callable = try layouts.insertErasedCallable(); + const old_capture = try testLocal(&store, .u64); + const new_capture = try testLocal(&store, .u64); + const old_callable = try testLocal(&store, erased_callable); + const new_callable = try testLocal(&store, erased_callable); + const callee_arg = try testLocal(&store, .u64); + + const old_proc = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{callee_arg}), + .frame_locals = try store.addLocalSpan(&.{callee_arg}), + .ret_layout = .u64, + }); + const new_proc = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{callee_arg}), + .frame_locals = try store.addLocalSpan(&.{callee_arg}), + .ret_layout = .u64, + }); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = new_callable } }); + const new_pack = try store.addCFStmt(.{ .assign_packed_erased_fn = .{ + .target = new_callable, + .proc = new_proc, + .capture = new_capture, + .capture_layout = .u64, + .on_drop = .none, + .next = ret, + } }); + const old_pack = try store.addCFStmt(.{ .assign_packed_erased_fn = .{ + .target = old_callable, + .proc = old_proc, + .capture = old_capture, + .capture_layout = .u64, + .on_drop = .none, + .next = new_pack, + } }); + const caller = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{}), + .frame_locals = try store.addLocalSpan(&.{ old_capture, new_capture, old_callable, new_callable }), + .body = old_pack, + .ret_layout = erased_callable, + }); + + try run(&store, &layouts); + + const rewritten = store.getCFStmt(new_pack).assign_packed_erased_fn; + try std.testing.expectEqual(old_callable, rewritten.reuse.?); + try std.testing.expect(!rewritten.reuse_unique); + + const frame_locals = store.getLocalSpan(store.getProcSpec(caller).frame_locals); + try std.testing.expectEqual(@as(usize, 4), frame_locals.len); +} diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index 01eac09f2a4..bd9c3f53a26 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -12,6 +12,8 @@ const core = @import("lir_core"); const Arc = @import("arc.zig"); const Trmc = @import("trmc.zig"); +const BoxReuse = @import("box_reuse.zig"); +const ReturnSlot = @import("return_slot.zig"); const ScalarizeJoins = @import("scalarize_joins.zig"); const TagReachability = @import("tag_reachability.zig"); const ReachableProcs = @import("reachable_procs.zig"); @@ -214,6 +216,8 @@ pub fn lowerCheckedModulesToLir( .{ .proc_debug_names = target.proc_debug_names, .specialization_cache = target.monotype_cache, + .static_data_literals = target.checked_module_state == .complete and roots.include_static_data_exports, + .target_usize = target.target_usize, .inline_expects = switch (target.inline_expects) { .run => .run, .omit => .omit, @@ -258,6 +262,8 @@ pub fn lowerCheckedModulesToLir( // statements (see src/lir/trmc.zig). try Trmc.run(&lowered.lir_result.store, &lowered.lir_result.layouts); try ScalarizeJoins.run(&lowered.lir_result.store, &lowered.lir_result.layouts); + try BoxReuse.run(&lowered.lir_result.store, &lowered.lir_result.layouts); + try ReturnSlot.run(&lowered.lir_result.store, &lowered.lir_result.layouts); if (target.tag_reachability) { try TagReachability.run(&lowered.lir_result); } @@ -391,7 +397,10 @@ fn collectStaticDataRequests( switch (provided) { .data => |data| { if (try checkedTypeContainsFunction(allocator, root.checked_types.view(), data.checked_type)) { - try requests.append(allocator, .{ .data = data }); + try requests.append(allocator, .{ + .const_locator = data.const_ref, + .checked_type = data.checked_type, + }); } }, .procedure => {}, diff --git a/src/lir/debug_print.zig b/src/lir/debug_print.zig index caf4c4c65b2..248d9edfdde 100644 --- a/src/lir/debug_print.zig +++ b/src/lir/debug_print.zig @@ -94,6 +94,7 @@ const Printer = struct { .f32_literal => |f| try writer.print("literal f32 {d}", .{f}), .dec_literal => |d| try writer.print("literal dec {d}", .{d}), .str_literal => try writer.writeAll("literal str"), + .static_data => |id| try writer.print("literal static_data s{d}", .{@intFromEnum(id)}), .bytes_literal => try writer.writeAll("literal bytes"), .null_ptr => try writer.writeAll("literal null_ptr"), .proc_ref => |p| try writer.print("literal proc_ref p{d}", .{@intFromEnum(p)}), @@ -124,7 +125,12 @@ const Printer = struct { }, .assign_packed_erased_fn => |s| { try self.writeTarget(s.target, indent, writer); - try writer.print("packed_erased_fn p{d}\n", .{@intFromEnum(s.proc)}); + try writer.print("packed_erased_fn p{d}", .{@intFromEnum(s.proc)}); + if (s.reuse) |reuse| { + try writer.print(" reuse=l{d}", .{@intFromEnum(reuse)}); + if (s.reuse_unique) try writer.writeAll(" unique"); + } + try writer.writeByte('\n'); current = s.next; }, .assign_low_level => |s| { @@ -155,6 +161,24 @@ const Printer = struct { try writer.writeAll("\n"); current = s.next; }, + .store_struct => |s| { + try writeIndent(indent, writer); + try writer.print("store_struct l{d} layout=", .{@intFromEnum(s.dest)}); + try writeLayout(self.layouts, s.struct_layout, writer); + try writer.writeAll("("); + try self.writeLocals(s.fields, writer); + try writer.writeAll(")\n"); + current = s.next; + }, + .store_tag => |s| { + try writeIndent(indent, writer); + try writer.print("store_tag l{d} layout=", .{@intFromEnum(s.dest)}); + try writeLayout(self.layouts, s.tag_layout, writer); + try writer.print(" v{d} d{d}", .{ s.variant_index, s.discriminant }); + if (s.payload) |payload| try writer.print(" (l{d})", .{@intFromEnum(payload)}); + try writer.writeAll("\n"); + current = s.next; + }, .set_local => |s| { try writeIndent(indent, writer); try writer.print("set l{d} := l{d} ({s})\n", .{ @intFromEnum(s.target), @intFromEnum(s.value), @tagName(s.mode) }); diff --git a/src/lir/mod.zig b/src/lir/mod.zig index 105d849974c..4c7a32f4e15 100644 --- a/src/lir/mod.zig +++ b/src/lir/mod.zig @@ -17,6 +17,12 @@ pub const Hosted = core.Hosted; pub const Program = core.Program; /// Public checked-module-to-LIR lowering entrypoint. pub const CheckedPipeline = @import("checked_pipeline.zig"); +/// Direct boxed update wrapper rewrite before ARC. +pub const BoxReuse = @import("box_reuse.zig"); +/// Internal aggregate return-slot variants before ARC. +pub const ReturnSlot = @import("return_slot.zig"); +/// Internal append-into-string variants before ARC. +pub const StrAppend = @import("str_append.zig"); /// Struct-typed join parameters split into per-field parameters before ARC. pub const ScalarizeJoins = @import("scalarize_joins.zig"); /// Switch branch pruning from explicit possible-tag analysis. @@ -52,6 +58,8 @@ pub const LocalSpan = LIR.LocalSpan; pub const JoinPointId = LIR.JoinPointId; /// Literal RHS values assignable in statement-only LIR. pub const LiteralValue = LIR.LiteralValue; +/// Identifier for a materialized readonly static-data value. +pub const StaticDataId = LIR.StaticDataId; /// Platform-hosted proc metadata. pub const HostedProc = LIR.HostedProc; /// Ref-producing operations lowerable by `assign_ref`. @@ -90,6 +98,9 @@ test "lir tests" { std.testing.refAllDecls(Program); std.testing.refAllDecls(ReachableProcs); std.testing.refAllDecls(CheckedPipeline); + std.testing.refAllDecls(BoxReuse); + std.testing.refAllDecls(ReturnSlot); + std.testing.refAllDecls(StrAppend); std.testing.refAllDecls(ScalarizeJoins); std.testing.refAllDecls(TagReachability); std.testing.refAllDecls(CheckedArithmetic); diff --git a/src/lir/program.zig b/src/lir/program.zig index 78a0beb63b9..3f6ef4ed01e 100644 --- a/src/lir/program.zig +++ b/src/lir/program.zig @@ -120,6 +120,20 @@ pub const ConstRootPlan = struct { plan: ConstPlanId, }; +/// One checked value that is materialized as readonly target data. +pub const StaticDataValue = struct { + const_locator: checked.ConstLocator, + node: ?checked.ConstNodeId = null, + checked_type: checked.CheckedTypeId, + layout_idx: layout.Idx, + plan: ConstPlanId, +}; + +/// Deterministic symbol name for an internal static-data value. +pub fn staticDataSymbolName(allocator: Allocator, id: LIR.StaticDataId) Allocator.Error![]u8 { + return try std.fmt.allocPrint(allocator, "roc__static_const_value_{d}", .{@intFromEnum(id)}); +} + /// Complete LIR program and side data consumed by ARC, backends, and eval. pub const Result = struct { store: LirStore, @@ -133,6 +147,7 @@ pub const Result = struct { erased_fns: std.ArrayList(ErasedFns), const_plans: std.ArrayList(ConstPlan), const_roots: std.ArrayList(ConstRootPlan), + static_data_values: std.ArrayList(StaticDataValue), comptime_sites: std.ArrayList(LIR.ComptimeSite), pub fn init(allocator: Allocator, target_usize: @import("base").target.TargetUsize) Allocator.Error!Result { @@ -148,6 +163,7 @@ pub const Result = struct { .erased_fns = .empty, .const_plans = .empty, .const_roots = .empty, + .static_data_values = .empty, .comptime_sites = .empty, }; } @@ -158,6 +174,7 @@ pub const Result = struct { allocator.free(site.branch_regions); } self.comptime_sites.deinit(allocator); + self.static_data_values.deinit(allocator); deinitConstPlans(allocator, self.const_plans.items); self.const_roots.deinit(allocator); self.const_plans.deinit(allocator); diff --git a/src/lir/reachable_procs.zig b/src/lir/reachable_procs.zig index e7074030fe5..c75ff9066dc 100644 --- a/src/lir/reachable_procs.zig +++ b/src/lir/reachable_procs.zig @@ -6,6 +6,7 @@ const std = @import("std"); const base = @import("base"); +const check = @import("check"); const collections = @import("collections"); const core = @import("lir_core"); @@ -34,6 +35,8 @@ const Pass = struct { old_stmt_to_new: []?LIR.CFStmtId, visited_stmts: []bool, visited_plans: []bool, + reachable_static_data: []bool, + old_static_data_to_new: []?LIR.StaticDataId, proc_queue: std.ArrayList(LIR.LirProcSpecId), stmt_stack: std.ArrayList(LIR.CFStmtId), @@ -42,6 +45,7 @@ const Pass = struct { const proc_count = result.store.procSpecCount(); const stmt_count = result.store.cfStmtCount(); const plan_count = result.const_plans.items.len; + const static_data_count = result.static_data_values.items.len; const reachable = try allocator.alloc(bool, proc_count); errdefer allocator.free(reachable); @@ -67,6 +71,14 @@ const Pass = struct { errdefer allocator.free(visited_plans); @memset(visited_plans, false); + const reachable_static_data = try allocator.alloc(bool, static_data_count); + errdefer allocator.free(reachable_static_data); + @memset(reachable_static_data, false); + + const old_static_data_to_new = try allocator.alloc(?LIR.StaticDataId, static_data_count); + errdefer allocator.free(old_static_data_to_new); + @memset(old_static_data_to_new, null); + return .{ .result = result, .store = &result.store, @@ -77,6 +89,8 @@ const Pass = struct { .old_stmt_to_new = old_stmt_to_new, .visited_stmts = visited_stmts, .visited_plans = visited_plans, + .reachable_static_data = reachable_static_data, + .old_static_data_to_new = old_static_data_to_new, .proc_queue = .empty, .stmt_stack = .empty, }; @@ -85,6 +99,8 @@ const Pass = struct { fn deinit(self: *Pass) void { self.stmt_stack.deinit(self.allocator); self.proc_queue.deinit(self.allocator); + self.allocator.free(self.old_static_data_to_new); + self.allocator.free(self.reachable_static_data); self.allocator.free(self.visited_plans); self.allocator.free(self.visited_stmts); self.allocator.free(self.old_stmt_to_new); @@ -108,6 +124,7 @@ const Pass = struct { try self.drainProcQueue(); self.assignCompactProcIds(); self.assignCompactStmtIds(); + self.assignCompactStaticDataIds(); try self.remapReachableProcBodies(); self.remapReachableProcStmtRefs(); self.remapRootProcs(); @@ -117,6 +134,7 @@ const Pass = struct { self.remapProcDebugNames(); self.compactProcSpecs(); self.compactCFStmts(); + self.compactStaticDataValues(); self.verifyReachableProcRefs(); } @@ -155,7 +173,11 @@ const Pass = struct { .init_uninitialized => |s| try self.pushStmt(s.next), .assign_ref => |s| try self.pushStmt(s.next), .assign_literal => |s| { - if (s.value == .proc_ref) try self.markProc(s.value.proc_ref); + switch (s.value) { + .proc_ref => |referenced_proc| try self.markProc(referenced_proc), + .static_data => |id| try self.markStaticData(id), + else => {}, + } try self.pushStmt(s.next); }, .assign_call => |s| { @@ -171,6 +193,8 @@ const Pass = struct { .assign_list => |s| try self.pushStmt(s.next), .assign_struct => |s| try self.pushStmt(s.next), .assign_tag => |s| try self.pushStmt(s.next), + .store_struct => |s| try self.pushStmt(s.next), + .store_tag => |s| try self.pushStmt(s.next), .set_local => |s| try self.pushStmt(s.next), .debug => |s| try self.pushStmt(s.next), .expect => |s| try self.pushStmt(s.next), @@ -252,6 +276,14 @@ const Pass = struct { } } + fn markStaticData(self: *Pass, id: LIR.StaticDataId) Allocator.Error!void { + const index = @intFromEnum(id); + if (index >= self.result.static_data_values.items.len) reachableProcInvariant("static data reference exceeds static_data_values len"); + if (self.reachable_static_data[index]) return; + self.reachable_static_data[index] = true; + try self.markConstPlan(self.result.static_data_values.items[index].plan); + } + fn markFnSet(self: *Pass, set_id: LirProgram.FnSetId) Allocator.Error!void { const set = self.result.fn_sets.items[@intFromEnum(set_id)]; for (set.variants) |variant| { @@ -285,13 +317,26 @@ const Pass = struct { } } + fn assignCompactStaticDataIds(self: *Pass) void { + var next: u32 = 0; + for (self.reachable_static_data, 0..) |is_reachable, old_index| { + if (!is_reachable) continue; + self.old_static_data_to_new[old_index] = @enumFromInt(next); + next += 1; + } + } + fn remapReachableProcBodies(self: *Pass) Allocator.Error!void { @memset(self.visited_stmts, false); for (0..self.store.procSpecCount()) |index| { if (!self.reachable[index]) continue; const proc = self.store.getProcSpec(@enumFromInt(@as(u32, @intCast(index)))); - const body = proc.body orelse continue; - try self.remapStmtProcRefs(body); + if (proc.body) |body| try self.remapStmtProcRefs(body); + const join_points = self.store.getJoinPointSpan(proc.join_points); + for (0..join_points.len) |join_index| { + const join_point = GuardedList.at(join_points, join_index); + try self.remapStmtProcRefs(join_point.body); + } } } @@ -305,7 +350,11 @@ const Pass = struct { const stmt = self.store.getCFStmtPtr(stmt_id); switch (stmt.*) { .assign_literal => |*s| { - if (s.value == .proc_ref) s.value.proc_ref = self.remapProc(s.value.proc_ref); + switch (s.value) { + .proc_ref => s.value.proc_ref = self.remapProc(s.value.proc_ref), + .static_data => s.value.static_data = self.remapStaticData(s.value.static_data), + else => {}, + } const next = s.next; s.next = self.remapStmt(next); try self.pushStmt(next); @@ -381,6 +430,8 @@ const Pass = struct { .assign_list, .assign_struct, .assign_tag, + .store_struct, + .store_tag, .set_local, .debug, .expect, @@ -485,6 +536,16 @@ const Pass = struct { self.store.compactCFStmts(self.reachable_stmts); } + fn compactStaticDataValues(self: *Pass) void { + var write: usize = 0; + for (self.result.static_data_values.items, 0..) |value, old_index| { + if (!self.reachable_static_data[old_index]) continue; + self.result.static_data_values.items[write] = value; + write += 1; + } + self.result.static_data_values.shrinkRetainingCapacity(write); + } + fn verifyReachableProcRefs(self: *Pass) void { const proc_count = self.store.procSpecCount(); const stmt_count = self.store.cfStmtCount(); @@ -523,6 +584,12 @@ const Pass = struct { return self.old_to_new[index]; } + fn remapStaticData(self: *Pass, old: LIR.StaticDataId) LIR.StaticDataId { + const index = @intFromEnum(old); + if (index >= self.old_static_data_to_new.len) reachableProcInvariant("static data reference exceeds old static_data_values len"); + return self.old_static_data_to_new[index] orelse reachableProcInvariant("reachable static data edge pointed at pruned static data"); + } + fn remapStmt(self: *Pass, old: LIR.CFStmtId) LIR.CFStmtId { const index = @intFromEnum(old); if (index >= self.old_stmt_to_new.len) reachableProcInvariant("stmt reference exceeds old cf_stmts len"); @@ -533,6 +600,12 @@ const Pass = struct { if (@intFromEnum(proc) >= proc_count) reachableProcInvariant("stmt proc reference exceeds compact proc_specs len"); } + fn verifyStaticDataRef(self: *Pass, id: LIR.StaticDataId) void { + if (@intFromEnum(id) >= self.result.static_data_values.items.len) { + reachableProcInvariant("stmt static data reference exceeds compact static_data_values len"); + } + } + fn verifyStmtRef(_: *Pass, stmt: LIR.CFStmtId, stmt_count: usize) void { if (@intFromEnum(stmt) >= stmt_count) reachableProcInvariant("stmt edge exceeds compact cf_stmts len"); } @@ -540,7 +613,11 @@ const Pass = struct { fn verifyStmtRefs(self: *Pass, stmt: LIR.CFStmt, proc_count: usize, stmt_count: usize) void { switch (stmt) { .assign_literal => |s| { - if (s.value == .proc_ref) self.verifyProcRef(s.value.proc_ref, proc_count); + switch (s.value) { + .proc_ref => |proc| self.verifyProcRef(proc, proc_count), + .static_data => |id| self.verifyStaticDataRef(id), + else => {}, + } self.verifyStmtRef(s.next, stmt_count); }, .assign_call => |s| { @@ -558,6 +635,8 @@ const Pass = struct { .assign_list, .assign_struct, .assign_tag, + .store_struct, + .store_tag, .set_local, .debug, .expect, @@ -674,3 +753,189 @@ test "reachable proc pass compacts proc specs and remaps root ids" { const call = result.store.getCFStmt(compact_root.body.?).assign_call; try std.testing.expectEqual(@as(u32, 0), @intFromEnum(call.proc)); } + +test "reachable proc pass marks static data callable plans" { + var result = try LirProgram.Result.init(std.testing.allocator, base.target.TargetUsize.native); + defer result.deinit(); + + const value = try result.store.addLocal(.{ .layout_idx = .zst }); + const callable_body = try result.store.addCFStmt(.{ .ret = .{ .value = value } }); + const callable_proc = try result.store.addProcSpec(.{ + .name = result.store.freshSyntheticSymbol(), + .args = LIR.LocalSpan.empty(), + .body = callable_body, + .ret_layout = .zst, + }); + + const erased_entries = try std.testing.allocator.alloc(LirProgram.ErasedFn, 1); + erased_entries[0] = .{ + .entry = callable_proc, + .template = .{ + .fn_def = .{ + .checked_generated = .{ + .proc_base = undefined, // Reachability tests only need the callable entry proc, not checked metadata. + .template = undefined, // Reachability tests only need the callable entry proc, not checked metadata. + }, + }, + .source_fn_ty = undefined, // Reachability tests only need the callable entry proc, not checked metadata. + .source_fn_key = .{}, + }, + }; + const erased_set: LirProgram.ErasedFnsId = @enumFromInt(@as(u32, @intCast(result.erased_fns.items.len))); + try result.erased_fns.append(std.testing.allocator, .{ + .layout = .zst, + .entries = erased_entries, + }); + + const plan: LirProgram.ConstPlanId = @enumFromInt(@as(u32, @intCast(result.const_plans.items.len))); + try result.const_plans.append(std.testing.allocator, .{ .erased_fn = erased_set }); + const static_data: LIR.StaticDataId = @enumFromInt(@as(u32, @intCast(result.static_data_values.items.len))); + try result.static_data_values.append(std.testing.allocator, .{ + .const_locator = .{ + .artifact = .{}, + .owner = .{ + .top_level_binding = .{ + .module_idx = 0, + .pattern = undefined, // Reachability tests do not inspect checked const owner metadata. + }, + }, + .template = undefined, // Reachability tests do not inspect checked const owner metadata. + .source_scheme = .{}, + }, + .node = null, + .checked_type = undefined, // Reachability tests do not inspect checked const type metadata. + .layout_idx = .zst, + .plan = plan, + }); + + const root_ret = try result.store.addCFStmt(.{ .ret = .{ .value = value } }); + const root_body = try result.store.addCFStmt(.{ .assign_literal = .{ + .target = value, + .value = .{ .static_data = static_data }, + .next = root_ret, + } }); + const root_proc = try result.store.addProcSpec(.{ + .name = result.store.freshSyntheticSymbol(), + .args = LIR.LocalSpan.empty(), + .body = root_body, + .ret_layout = .zst, + }); + try result.root_procs.append(std.testing.allocator, root_proc); + + try run(&result); + + try std.testing.expectEqual(@as(usize, 2), result.store.procSpecCount()); + try std.testing.expectEqual(@as(usize, 1), result.static_data_values.items.len); + try std.testing.expectEqual(@as(usize, 1), result.erased_fns.items[@intFromEnum(erased_set)].entries.len); + try std.testing.expectEqual(@as(u32, 0), @intFromEnum(result.erased_fns.items[@intFromEnum(erased_set)].entries[0].entry)); + try std.testing.expectEqual(@as(u32, 1), @intFromEnum(result.root_procs.items[0])); +} + +test "reachable proc pass marks finite callable capture plans" { + var result = try LirProgram.Result.init(std.testing.allocator, base.target.TargetUsize.native); + defer result.deinit(); + + const value = try result.store.addLocal(.{ .layout_idx = .zst }); + const callable_body = try result.store.addCFStmt(.{ .ret = .{ .value = value } }); + const callable_proc = try result.store.addProcSpec(.{ + .name = result.store.freshSyntheticSymbol(), + .args = LIR.LocalSpan.empty(), + .body = callable_body, + .ret_layout = .zst, + }); + + const erased_entries = try std.testing.allocator.alloc(LirProgram.ErasedFn, 1); + erased_entries[0] = .{ + .entry = callable_proc, + .template = .{ + .fn_def = .{ + .checked_generated = .{ + .proc_base = undefined, // Reachability tests only need the callable entry proc, not checked metadata. + .template = undefined, // Reachability tests only need the callable entry proc, not checked metadata. + }, + }, + .source_fn_ty = undefined, // Reachability tests only need the callable entry proc, not checked metadata. + .source_fn_key = .{}, + }, + }; + const erased_set: LirProgram.ErasedFnsId = @enumFromInt(@as(u32, @intCast(result.erased_fns.items.len))); + try result.erased_fns.append(std.testing.allocator, .{ + .layout = .zst, + .entries = erased_entries, + }); + + const erased_plan: LirProgram.ConstPlanId = @enumFromInt(@as(u32, @intCast(result.const_plans.items.len))); + try result.const_plans.append(std.testing.allocator, .{ .erased_fn = erased_set }); + + const finite_captures = try std.testing.allocator.alloc(LirProgram.CaptureSlot, 1); + finite_captures[0] = .{ + .id = check.ConstStore.CaptureId.generatedLift(0), + .slot = 0, + .ty = undefined, // Reachability tests do not inspect checked capture types. + .plan = erased_plan, + }; + const finite_variants = try std.testing.allocator.alloc(LirProgram.FnVariant, 1); + finite_variants[0] = .{ + .id = undefined, // Reachability tests do not inspect callable variant metadata ids. + .discriminant = 0, + .variant_index = 0, + .payload_layout = .zst, + .template = .{ + .fn_def = .{ .checked_generated = .{ + .proc_base = @enumFromInt(1), + .template = @enumFromInt(1), + } }, + .source_fn_ty = @enumFromInt(1), + .source_fn_key = .{}, + }, + .captures = finite_captures, + }; + const fn_set: LirProgram.FnSetId = @enumFromInt(@as(u32, @intCast(result.fn_sets.items.len))); + try result.fn_sets.append(std.testing.allocator, .{ + .layout = .zst, + .variants = finite_variants, + }); + + const finite_plan: LirProgram.ConstPlanId = @enumFromInt(@as(u32, @intCast(result.const_plans.items.len))); + try result.const_plans.append(std.testing.allocator, .{ .fn_value = fn_set }); + const static_data: LIR.StaticDataId = @enumFromInt(@as(u32, @intCast(result.static_data_values.items.len))); + try result.static_data_values.append(std.testing.allocator, .{ + .const_locator = .{ + .artifact = .{}, + .owner = .{ + .top_level_binding = .{ + .module_idx = 0, + .pattern = undefined, // Reachability tests do not inspect checked const owner metadata. + }, + }, + .template = undefined, // Reachability tests do not inspect checked const owner metadata. + .source_scheme = .{}, + }, + .node = null, + .checked_type = undefined, // Reachability tests do not inspect checked const type metadata. + .layout_idx = .zst, + .plan = finite_plan, + }); + + const root_ret = try result.store.addCFStmt(.{ .ret = .{ .value = value } }); + const root_body = try result.store.addCFStmt(.{ .assign_literal = .{ + .target = value, + .value = .{ .static_data = static_data }, + .next = root_ret, + } }); + const root_proc = try result.store.addProcSpec(.{ + .name = result.store.freshSyntheticSymbol(), + .args = LIR.LocalSpan.empty(), + .body = root_body, + .ret_layout = .zst, + }); + try result.root_procs.append(std.testing.allocator, root_proc); + + try run(&result); + + try std.testing.expectEqual(@as(usize, 2), result.store.procSpecCount()); + try std.testing.expectEqual(@as(usize, 1), result.static_data_values.items.len); + try std.testing.expectEqual(@as(usize, 1), result.erased_fns.items[@intFromEnum(erased_set)].entries.len); + try std.testing.expectEqual(@as(u32, 0), @intFromEnum(result.erased_fns.items[@intFromEnum(erased_set)].entries[0].entry)); + try std.testing.expectEqual(@as(u32, 1), @intFromEnum(result.root_procs.items[0])); +} diff --git a/src/lir/return_slot.zig b/src/lir/return_slot.zig new file mode 100644 index 00000000000..9fbf20409e9 --- /dev/null +++ b/src/lir/return_slot.zig @@ -0,0 +1,918 @@ +//! Creates explicit internal return-slot proc variants for by-memory aggregate +//! results when the caller already has a concrete destination pointer. +//! +//! This runs after structural rewrites such as BoxReuse and before ARC. It +//! consumes this adjacent LIR shape, allowing only intervening `assign_ref +//! .local` aliases of the call result: +//! +//! ```text +//! result = call(args...) +//! _ = ptr_store(destination, result) +//! ``` +//! +//! for aggregate layouts that are represented by memory. The generated variant +//! has the ordinary explicit signature: +//! +//! ```text +//! call_slot(out: ptr(T), args...) -> {} +//! ``` +//! +//! Its body uses the base rule from design.md for general returns: materialize +//! the return value, then store it into `out`. When the original body ends in a +//! direct struct or tag construction, the variant writes that aggregate into +//! `out` with an explicit destination store instead of building a temporary. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const core = @import("lir_core"); +const layout_mod = @import("layout"); + +const LIR = core.LIR; +const LirStore = core.LirStore; +const GuardedList = LirStore.GuardedList; +const CFStmtId = LIR.CFStmtId; +const LocalId = LIR.LocalId; +const LowLevelOp = LIR.LowLevel; + +/// Allocation failure raised while rewriting returned aggregate statements. +pub const ResourceError = Allocator.Error; + +/// Rewrite eligible aggregate returns to destination-slot helper calls. +pub fn run(store: *LirStore, layouts: *layout_mod.Store) ResourceError!void { + var pass = ReturnSlotPass{ + .store = store, + .layouts = layouts, + .variants = std.AutoHashMap(VariantKey, LIR.LirProcSpecId).init(store.allocator), + }; + defer pass.variants.deinit(); + + const proc_count = store.procSpecCount(); + var proc_index: usize = 0; + while (proc_index < proc_count) : (proc_index += 1) { + try pass.transformProc(@enumFromInt(proc_index)); + } +} + +const VariantKey = struct { + source: LIR.LirProcSpecId, + result_layout: layout_mod.Idx, +}; + +const ReturnSlotPass = struct { + store: *LirStore, + layouts: *layout_mod.Store, + variants: std.AutoHashMap(VariantKey, LIR.LirProcSpecId), + + fn transformProc(self: *ReturnSlotPass, proc_id: LIR.LirProcSpecId) ResourceError!void { + const proc = self.store.getProcSpec(proc_id); + if (proc.body == null or proc.hosted != null or proc.abi != .roc) return; + + var work = std.ArrayList(CFStmtId).empty; + defer work.deinit(self.store.allocator); + var visited = std.AutoHashMap(CFStmtId, void).init(self.store.allocator); + defer visited.deinit(); + + try work.append(self.store.allocator, proc.body.?); + while (work.pop()) |stmt_id| { + const entry = try visited.getOrPut(stmt_id); + if (entry.found_existing) continue; + + _ = try self.rewriteAt(stmt_id); + try self.appendSuccessors(&work, stmt_id); + } + } + + fn rewriteAt(self: *ReturnSlotPass, call_stmt_id: CFStmtId) ResourceError!bool { + const call_stmt = switch (self.store.getCFStmt(call_stmt_id)) { + .assign_call => |s| s, + else => return false, + }; + + const result_layout = self.store.getLocal(call_stmt.target).layout_idx; + if (!self.returnSlotEligible(result_layout)) return false; + + const callee = self.store.getProcSpec(call_stmt.proc); + if (callee.body == null or callee.hosted != null or callee.abi != .roc) return false; + if (callee.ret_layout != result_layout) return false; + + const stored_alias = self.forwardLocalAliasChain(call_stmt.target, call_stmt.next); + const stored_value = stored_alias.value; + const store_stmt_id = stored_alias.next; + const store_stmt = switch (self.store.getCFStmt(store_stmt_id)) { + .assign_low_level => |s| s, + else => return false, + }; + if (store_stmt.op != .ptr_store) return false; + if (self.store.getLocal(store_stmt.target).layout_idx != .zst) return false; + + const store_args = self.store.getLocalSpan(store_stmt.args); + if (store_args.len != 2) return false; + const destination = GuardedList.at(store_args, 0); + if (GuardedList.at(store_args, 1) != stored_value) return false; + + const destination_layout = self.layouts.getLayout(self.store.getLocal(destination).layout_idx); + if (destination_layout.tag != .ptr) return false; + if (destination_layout.getIdx() != result_layout) return false; + + const variant = try self.returnSlotVariant(call_stmt.proc, result_layout); + + var args = std.ArrayList(LocalId).empty; + defer args.deinit(self.store.allocator); + try args.append(self.store.allocator, destination); + const call_args = self.store.getLocalSpan(call_stmt.args); + for (0..call_args.len) |index| { + try args.append(self.store.allocator, GuardedList.at(call_args, index)); + } + + self.store.getCFStmtPtr(call_stmt_id).* = .{ .assign_call = .{ + .target = store_stmt.target, + .proc = variant, + .args = try self.store.addLocalSpan(args.items), + .is_cold = call_stmt.is_cold, + .next = store_stmt.next, + } }; + + return true; + } + + const ForwardedAlias = struct { + value: LocalId, + next: CFStmtId, + }; + + fn forwardLocalAliasChain(self: *const ReturnSlotPass, source: LocalId, first_stmt: CFStmtId) ForwardedAlias { + var value = source; + var current = first_stmt; + while (true) { + const stmt = switch (self.store.getCFStmt(current)) { + .assign_ref => |s| s, + else => return .{ .value = value, .next = current }, + }; + switch (stmt.op) { + .local => |local| if (local == value and self.store.getLocal(stmt.target).layout_idx == self.store.getLocal(value).layout_idx) { + value = stmt.target; + current = stmt.next; + continue; + }, + else => {}, + } + return .{ .value = value, .next = current }; + } + } + + fn returnSlotEligible(self: *const ReturnSlotPass, result_layout: layout_mod.Idx) bool { + return switch (self.layouts.getLayout(result_layout).tag) { + .struct_, .tag_union => true, + .scalar, + .box, + .box_of_zst, + .list, + .list_of_zst, + .closure, + .erased_callable, + .zst, + .ptr, + => false, + }; + } + + fn returnSlotVariant( + self: *ReturnSlotPass, + source: LIR.LirProcSpecId, + result_layout: layout_mod.Idx, + ) ResourceError!LIR.LirProcSpecId { + const key = VariantKey{ .source = source, .result_layout = result_layout }; + if (self.variants.get(key)) |variant| return variant; + + const variant = try self.createReturnSlotVariant(source, result_layout); + try self.variants.put(key, variant); + return variant; + } + + fn createReturnSlotVariant( + self: *ReturnSlotPass, + source: LIR.LirProcSpecId, + result_layout: layout_mod.Idx, + ) ResourceError!LIR.LirProcSpecId { + const source_spec = self.store.getProcSpec(source); + const source_body = source_spec.body orelse unreachable; + const source_args = self.store.getLocalSpan(source_spec.args); + const out_ptr_layout = try self.layouts.insertPtr(result_layout); + + const out_ptr = try self.store.addLocal(.{ .layout_idx = out_ptr_layout }); + const store_unit = try self.store.addLocal(.{ .layout_idx = .zst }); + + var variant_args = try std.ArrayList(LocalId).initCapacity(self.store.allocator, source_args.len + 1); + defer variant_args.deinit(self.store.allocator); + variant_args.appendAssumeCapacity(out_ptr); + + for (0..source_args.len) |index| { + const source_arg = GuardedList.at(source_args, index); + const arg = try self.store.addLocal(.{ .layout_idx = self.store.getLocal(source_arg).layout_idx }); + variant_args.appendAssumeCapacity(arg); + } + + var cloner = try BodyCloner.init(self.store, out_ptr, store_unit); + defer cloner.deinit(); + + for (0..source_args.len) |index| { + const source_arg = GuardedList.at(source_args, index); + const variant_arg = variant_args.items[index + 1]; + cloner.local_map[@intFromEnum(source_arg)] = variant_arg; + } + + const source_frame = self.store.getLocalSpan(source_spec.frame_locals); + try cloner.new_locals.appendSlice(self.store.allocator, variant_args.items); + try cloner.new_locals.append(self.store.allocator, store_unit); + for (0..source_frame.len) |index| { + _ = try cloner.mapLocal(GuardedList.at(source_frame, index)); + } + + const body = try cloner.cloneStmt(source_body); + + var frame_locals = try std.ArrayList(LocalId).initCapacity(self.store.allocator, cloner.new_locals.items.len); + defer frame_locals.deinit(self.store.allocator); + frame_locals.appendSliceAssumeCapacity(cloner.new_locals.items); + std.mem.sort(LocalId, frame_locals.items, {}, localIdLessThan); + const unique_len = uniqueSortedLocals(frame_locals.items); + + const variant = try self.store.addProcSpec(.{ + .name = self.store.freshSyntheticSymbol(), + .args = try self.store.addLocalSpan(variant_args.items), + .frame_locals = try self.store.addLocalSpan(frame_locals.items[0..unique_len]), + .body = body, + .ret_layout = .zst, + .abi = .roc, + }); + try self.store.copyProcDebugInfo(variant, source); + + return variant; + } + + fn appendSuccessors( + self: *ReturnSlotPass, + work: *std.ArrayList(CFStmtId), + stmt_id: CFStmtId, + ) ResourceError!void { + switch (self.store.getCFStmt(stmt_id)) { + inline .assign_ref, + .assign_literal, + .init_uninitialized, + .assign_call, + .assign_call_erased, + .assign_packed_erased_fn, + .assign_low_level, + .assign_list, + .assign_struct, + .assign_tag, + .store_struct, + .store_tag, + .set_local, + .debug, + .expect, + .comptime_branch_taken, + .incref, + .decref, + .decref_if_initialized, + .free, + => |s| try work.append(self.store.allocator, s.next), + + .switch_stmt => |s| { + if (s.continuation) |continuation| try work.append(self.store.allocator, continuation); + try work.append(self.store.allocator, s.default_branch); + const branches = self.store.getCFSwitchBranches(s.branches); + for (0..branches.len) |index| { + try work.append(self.store.allocator, GuardedList.at(branches, index).body); + } + }, + .switch_initialized_payload => |s| { + try work.append(self.store.allocator, s.initialized_branch); + try work.append(self.store.allocator, s.uninitialized_branch); + }, + .str_match => |s| { + try work.append(self.store.allocator, s.on_match); + try work.append(self.store.allocator, s.on_miss); + }, + .str_match_set => |s| { + const arms = self.store.getStrMatchArms(s.arms); + for (0..arms.len) |index| { + try work.append(self.store.allocator, GuardedList.at(arms, index).on_match); + } + try work.append(self.store.allocator, s.on_miss); + }, + .join => |s| { + try work.append(self.store.allocator, s.body); + try work.append(self.store.allocator, s.remainder); + }, + .runtime_error, + .comptime_exhaustiveness_failed, + .expect_err, + .loop_continue, + .loop_break, + .jump, + .ret, + .crash, + => {}, + } + } + + fn localIdLessThan(_: void, a: LocalId, b: LocalId) bool { + return @intFromEnum(a) < @intFromEnum(b); + } +}; + +const BodyCloner = struct { + store: *LirStore, + out_ptr: LocalId, + store_unit: LocalId, + local_map: []?LocalId, + stmt_map: std.AutoHashMap(CFStmtId, CFStmtId), + new_locals: std.ArrayList(LocalId), + + fn init(store: *LirStore, out_ptr: LocalId, store_unit: LocalId) ResourceError!BodyCloner { + const local_map = try store.allocator.alloc(?LocalId, store.localCount()); + @memset(local_map, null); + return .{ + .store = store, + .out_ptr = out_ptr, + .store_unit = store_unit, + .local_map = local_map, + .stmt_map = std.AutoHashMap(CFStmtId, CFStmtId).init(store.allocator), + .new_locals = .empty, + }; + } + + fn deinit(self: *BodyCloner) void { + self.new_locals.deinit(self.store.allocator); + self.stmt_map.deinit(); + self.store.allocator.free(self.local_map); + } + + fn cloneStmt(self: *BodyCloner, old_id: CFStmtId) ResourceError!CFStmtId { + if (self.stmt_map.get(old_id)) |existing| return existing; + + const cloned = switch (self.store.getCFStmt(old_id)) { + .init_uninitialized => |s| try self.store.addCFStmt(.{ .init_uninitialized = .{ + .target = try self.mapLocal(s.target), + .next = try self.cloneStmt(s.next), + } }), + .assign_ref => |s| try self.store.addCFStmt(.{ .assign_ref = .{ + .target = try self.mapLocal(s.target), + .op = try self.mapRefOp(s.op), + .next = try self.cloneStmt(s.next), + } }), + .assign_literal => |s| try self.store.addCFStmt(.{ .assign_literal = .{ + .target = try self.mapLocal(s.target), + .value = s.value, + .next = try self.cloneStmt(s.next), + } }), + .assign_call => |s| try self.store.addCFStmt(.{ .assign_call = .{ + .target = try self.mapLocal(s.target), + .proc = s.proc, + .args = try self.mapLocalSpan(s.args), + .is_cold = s.is_cold, + .next = try self.cloneStmt(s.next), + } }), + .assign_call_erased => |s| try self.store.addCFStmt(.{ .assign_call_erased = .{ + .target = try self.mapLocal(s.target), + .closure = try self.mapLocal(s.closure), + .args = try self.mapLocalSpan(s.args), + .next = try self.cloneStmt(s.next), + } }), + .assign_packed_erased_fn => |s| try self.store.addCFStmt(.{ .assign_packed_erased_fn = .{ + .target = try self.mapLocal(s.target), + .proc = s.proc, + .capture = try self.mapMaybeLocal(s.capture), + .capture_layout = s.capture_layout, + .on_drop = s.on_drop, + .reuse = try self.mapMaybeLocal(s.reuse), + .reuse_unique = s.reuse_unique, + .next = try self.cloneStmt(s.next), + } }), + .assign_low_level => |s| try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = try self.mapLocal(s.target), + .op = s.op, + .rc_effect = s.rc_effect, + .unique_args = s.unique_args, + .args = try self.mapLocalSpan(s.args), + .next = try self.cloneStmt(s.next), + } }), + .assign_list => |s| try self.store.addCFStmt(.{ .assign_list = .{ + .target = try self.mapLocal(s.target), + .elems = try self.mapLocalSpan(s.elems), + .next = try self.cloneStmt(s.next), + } }), + .assign_struct => |s| if (self.directReturnOf(s.next, s.target)) + try self.cloneStructReturn(s) + else + try self.store.addCFStmt(.{ .assign_struct = .{ + .target = try self.mapLocal(s.target), + .fields = try self.mapLocalSpan(s.fields), + .next = try self.cloneStmt(s.next), + } }), + .assign_tag => |s| if (self.directReturnOf(s.next, s.target)) + try self.cloneTagReturn(s) + else + try self.store.addCFStmt(.{ .assign_tag = .{ + .target = try self.mapLocal(s.target), + .variant_index = s.variant_index, + .discriminant = s.discriminant, + .payload = try self.mapMaybeLocal(s.payload), + .next = try self.cloneStmt(s.next), + } }), + .store_struct => |s| try self.store.addCFStmt(.{ .store_struct = .{ + .dest = try self.mapLocal(s.dest), + .struct_layout = s.struct_layout, + .fields = try self.mapLocalSpan(s.fields), + .next = try self.cloneStmt(s.next), + } }), + .store_tag => |s| try self.store.addCFStmt(.{ .store_tag = .{ + .dest = try self.mapLocal(s.dest), + .tag_layout = s.tag_layout, + .variant_index = s.variant_index, + .discriminant = s.discriminant, + .payload = try self.mapMaybeLocal(s.payload), + .next = try self.cloneStmt(s.next), + } }), + .set_local => |s| try self.store.addCFStmt(.{ .set_local = .{ + .target = try self.mapLocal(s.target), + .value = try self.mapLocal(s.value), + .mode = s.mode, + .next = try self.cloneStmt(s.next), + } }), + .debug => |s| try self.store.addCFStmt(.{ .debug = .{ + .message = try self.mapLocal(s.message), + .next = try self.cloneStmt(s.next), + } }), + .expect => |s| try self.store.addCFStmt(.{ .expect = .{ + .condition = try self.mapLocal(s.condition), + .next = try self.cloneStmt(s.next), + } }), + .expect_err => |s| try self.store.addCFStmt(.{ .expect_err = .{ + .message = try self.mapLocal(s.message), + .region = s.region, + } }), + .runtime_error => try self.store.addCFStmt(.runtime_error), + .comptime_exhaustiveness_failed => |s| try self.store.addCFStmt(.{ .comptime_exhaustiveness_failed = .{ + .site = s.site, + } }), + .comptime_branch_taken => |s| try self.store.addCFStmt(.{ .comptime_branch_taken = .{ + .site = s.site, + .branch_index = s.branch_index, + .next = try self.cloneStmt(s.next), + } }), + .incref => |s| try self.store.addCFStmt(.{ .incref = .{ + .value = try self.mapLocal(s.value), + .rc = s.rc, + .count = s.count, + .atomicity = s.atomicity, + .next = try self.cloneStmt(s.next), + } }), + .decref => |s| try self.store.addCFStmt(.{ .decref = .{ + .value = try self.mapLocal(s.value), + .rc = s.rc, + .atomicity = s.atomicity, + .next = try self.cloneStmt(s.next), + } }), + .decref_if_initialized => |s| try self.store.addCFStmt(.{ .decref_if_initialized = .{ + .cond = try self.mapLocal(s.cond), + .cond_mask = s.cond_mask, + .value = try self.mapLocal(s.value), + .rc = s.rc, + .atomicity = s.atomicity, + .next = try self.cloneStmt(s.next), + } }), + .free => |s| try self.store.addCFStmt(.{ .free = .{ + .value = try self.mapLocal(s.value), + .rc = s.rc, + .atomicity = s.atomicity, + .next = try self.cloneStmt(s.next), + } }), + .switch_stmt => |s| try self.cloneSwitch(s), + .switch_initialized_payload => |s| try self.store.addCFStmt(.{ .switch_initialized_payload = .{ + .cond = try self.mapLocal(s.cond), + .cond_mask = s.cond_mask, + .payload = try self.mapLocal(s.payload), + .uninitialized_is_cold = s.uninitialized_is_cold, + .initialized_branch = try self.cloneStmt(s.initialized_branch), + .uninitialized_branch = try self.cloneStmt(s.uninitialized_branch), + } }), + .str_match => |s| try self.store.addCFStmt(.{ .str_match = .{ + .source = try self.mapLocal(s.source), + .prefix = s.prefix, + .steps = try self.mapStrMatchSteps(s.steps), + .end = s.end, + .on_match = try self.cloneStmt(s.on_match), + .on_miss = try self.cloneStmt(s.on_miss), + } }), + .str_match_set => |s| try self.cloneStrMatchSet(s), + .loop_continue => try self.store.addCFStmt(.loop_continue), + .loop_break => try self.store.addCFStmt(.loop_break), + .join => |s| try self.store.addCFStmt(.{ .join = .{ + .id = s.id, + .params = try self.mapLocalSpan(s.params), + .maybe_uninitialized_params = try self.mapLocalSpan(s.maybe_uninitialized_params), + .maybe_uninitialized_conditions = try self.mapLocalSpan(s.maybe_uninitialized_conditions), + .maybe_uninitialized_condition_masks = s.maybe_uninitialized_condition_masks, + .body = try self.cloneStmt(s.body), + .remainder = try self.cloneStmt(s.remainder), + } }), + .jump => |s| try self.store.addCFStmt(.{ .jump = .{ .target = s.target } }), + .ret => |s| try self.cloneRet(s.value), + .crash => |s| try self.store.addCFStmt(.{ .crash = .{ .msg = s.msg } }), + }; + + try self.stmt_map.put(old_id, cloned); + return cloned; + } + + fn cloneRet(self: *BodyCloner, value: LocalId) ResourceError!CFStmtId { + const ret_stmt = try self.store.addCFStmt(.{ .ret = .{ .value = self.store_unit } }); + return try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = self.store_unit, + .op = .ptr_store, + .rc_effect = LowLevelOp.ptr_store.rcEffect(), + .args = try self.store.addLocalSpan(&.{ self.out_ptr, try self.mapLocal(value) }), + .next = ret_stmt, + } }); + } + + fn directReturnOf(self: *const BodyCloner, next: CFStmtId, value: LocalId) bool { + return switch (self.store.getCFStmt(next)) { + .ret => |ret_stmt| ret_stmt.value == value, + else => false, + }; + } + + fn cloneStructReturn(self: *BodyCloner, s: anytype) ResourceError!CFStmtId { + const ret_stmt = try self.store.addCFStmt(.{ .ret = .{ .value = self.store_unit } }); + return try self.store.addCFStmt(.{ .store_struct = .{ + .dest = self.out_ptr, + .struct_layout = self.store.getLocal(s.target).layout_idx, + .fields = try self.mapLocalSpan(s.fields), + .next = ret_stmt, + } }); + } + + fn cloneTagReturn(self: *BodyCloner, s: anytype) ResourceError!CFStmtId { + const ret_stmt = try self.store.addCFStmt(.{ .ret = .{ .value = self.store_unit } }); + return try self.store.addCFStmt(.{ .store_tag = .{ + .dest = self.out_ptr, + .tag_layout = self.store.getLocal(s.target).layout_idx, + .variant_index = s.variant_index, + .discriminant = s.discriminant, + .payload = try self.mapMaybeLocal(s.payload), + .next = ret_stmt, + } }); + } + + fn cloneSwitch(self: *BodyCloner, s: anytype) ResourceError!CFStmtId { + const old_branches = self.store.getCFSwitchBranches(s.branches); + const branches = try self.store.allocator.alloc(LIR.CFSwitchBranch, old_branches.len); + defer self.store.allocator.free(branches); + for (0..old_branches.len) |index| { + const old = GuardedList.at(old_branches, index); + const new = &branches[index]; + new.* = .{ + .value = old.value, + .body = try self.cloneStmt(old.body), + }; + } + return try self.store.addCFStmt(.{ .switch_stmt = .{ + .cond = try self.mapLocal(s.cond), + .branches = try self.store.addCFSwitchBranches(branches), + .default_branch = try self.cloneStmt(s.default_branch), + .default_is_cold = s.default_is_cold, + .continuation = if (s.continuation) |continuation| try self.cloneStmt(continuation) else null, + } }); + } + + fn cloneStrMatchSet(self: *BodyCloner, s: anytype) ResourceError!CFStmtId { + const old_arms = self.store.getStrMatchArms(s.arms); + const arms = try self.store.allocator.alloc(LIR.StrMatchArm, old_arms.len); + defer self.store.allocator.free(arms); + for (0..old_arms.len) |index| { + const old = GuardedList.at(old_arms, index); + const new = &arms[index]; + new.* = .{ + .prefix = old.prefix, + .steps = try self.mapStrMatchSteps(old.steps), + .end = old.end, + .on_match = try self.cloneStmt(old.on_match), + }; + } + return try self.store.addCFStmt(.{ .str_match_set = .{ + .source = try self.mapLocal(s.source), + .arms = try self.store.addStrMatchArms(arms), + .on_miss = try self.cloneStmt(s.on_miss), + } }); + } + + fn mapStrMatchSteps(self: *BodyCloner, span: LIR.StrMatchStepSpan) ResourceError!LIR.StrMatchStepSpan { + const old_steps = self.store.getStrMatchSteps(span); + const steps = try self.store.allocator.alloc(LIR.StrMatchStep, old_steps.len); + defer self.store.allocator.free(steps); + for (0..old_steps.len) |index| { + const old = GuardedList.at(old_steps, index); + const new = &steps[index]; + new.* = old; + new.capture = switch (old.capture) { + .discard => .discard, + .view => |local| .{ .view = try self.mapLocal(local) }, + }; + } + return try self.store.addStrMatchSteps(steps); + } + + fn mapRefOp(self: *BodyCloner, op: LIR.RefOp) ResourceError!LIR.RefOp { + return switch (op) { + .local => |local| .{ .local = try self.mapLocal(local) }, + .discriminant => |d| .{ .discriminant = .{ .source = try self.mapLocal(d.source) } }, + .field => |f| .{ .field = .{ + .source = try self.mapLocal(f.source), + .field_idx = f.field_idx, + } }, + .tag_payload => |t| .{ .tag_payload = .{ + .source = try self.mapLocal(t.source), + .payload_idx = t.payload_idx, + .variant_index = t.variant_index, + .tag_discriminant = t.tag_discriminant, + } }, + .tag_payload_struct => |t| .{ .tag_payload_struct = .{ + .source = try self.mapLocal(t.source), + .variant_index = t.variant_index, + .tag_discriminant = t.tag_discriminant, + } }, + .list_reinterpret => |l| .{ .list_reinterpret = .{ .backing_ref = try self.mapLocal(l.backing_ref) } }, + .nominal => |n| .{ .nominal = .{ .backing_ref = try self.mapLocal(n.backing_ref) } }, + }; + } + + fn mapLocalSpan(self: *BodyCloner, span: LIR.LocalSpan) ResourceError!LIR.LocalSpan { + const old_locals = self.store.getLocalSpan(span); + const locals = try self.store.allocator.alloc(LocalId, old_locals.len); + defer self.store.allocator.free(locals); + for (0..old_locals.len) |index| { + locals[index] = try self.mapLocal(GuardedList.at(old_locals, index)); + } + return try self.store.addLocalSpan(locals); + } + + fn mapMaybeLocal(self: *BodyCloner, maybe: ?LocalId) ResourceError!?LocalId { + return if (maybe) |local| try self.mapLocal(local) else null; + } + + fn mapLocal(self: *BodyCloner, old: LocalId) ResourceError!LocalId { + const index = @intFromEnum(old); + if (index >= self.local_map.len) unreachable; + if (self.local_map[index]) |existing| return existing; + + const fresh = try self.store.addLocal(.{ .layout_idx = self.store.getLocal(old).layout_idx }); + self.local_map[index] = fresh; + try self.new_locals.append(self.store.allocator, fresh); + return fresh; + } +}; + +fn uniqueSortedLocals(items: []LocalId) usize { + var unique_len: usize = 0; + for (items, 0..) |local, idx| { + if (idx > 0 and items[unique_len - 1] == local) continue; + items[unique_len] = local; + unique_len += 1; + } + return unique_len; +} + +fn testLocal(store: *LirStore, layout_idx: layout_mod.Idx) ResourceError!LocalId { + return try store.addLocal(.{ .layout_idx = layout_idx }); +} + +fn testLowLevel(store: *LirStore, target: LocalId, op: LowLevelOp, args: []const LocalId, next: CFStmtId) ResourceError!CFStmtId { + return try store.addCFStmt(.{ .assign_low_level = .{ + .target = target, + .op = op, + .rc_effect = op.rcEffect(), + .args = try store.addLocalSpan(args), + .next = next, + } }); +} + +fn testStructLayout(layouts: *layout_mod.Store) ResourceError!layout_mod.Idx { + return try layouts.putStructFields(&.{ + .{ .index = 0, .layout = .u64 }, + .{ .index = 1, .layout = .u64 }, + }); +} + +fn testAggregateCallee(store: *LirStore, result_layout: layout_mod.Idx) ResourceError!LIR.LirProcSpecId { + const arg = try testLocal(store, .u64); + const result = try testLocal(store, result_layout); + const ret = try store.addCFStmt(.{ .ret = .{ .value = result } }); + const assign = try store.addCFStmt(.{ .assign_struct = .{ + .target = result, + .fields = try store.addLocalSpan(&.{ arg, arg }), + .next = ret, + } }); + return try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{arg}), + .frame_locals = try store.addLocalSpan(&.{ arg, result }), + .body = assign, + .ret_layout = result_layout, + }); +} + +fn testTagLayout(layouts: *layout_mod.Store) ResourceError!layout_mod.Idx { + return try layouts.putTagUnion(&.{.u64}); +} + +fn testTagCallee(store: *LirStore, result_layout: layout_mod.Idx) ResourceError!LIR.LirProcSpecId { + const arg = try testLocal(store, .u64); + const result = try testLocal(store, result_layout); + const ret = try store.addCFStmt(.{ .ret = .{ .value = result } }); + const assign = try store.addCFStmt(.{ .assign_tag = .{ + .target = result, + .variant_index = 0, + .discriminant = 0, + .payload = arg, + .next = ret, + } }); + return try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{arg}), + .frame_locals = try store.addLocalSpan(&.{ arg, result }), + .body = assign, + .ret_layout = result_layout, + }); +} + +test "return slot creates an explicit ptr-result variant for aggregate call stores" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout_mod.Store.init(allocator, @import("base").target.TargetUsize.native); + defer layouts.deinit(); + + const aggregate = try testStructLayout(&layouts); + const aggregate_ptr = try layouts.insertPtr(aggregate); + const callee = try testAggregateCallee(&store, aggregate); + + const destination = try testLocal(&store, aggregate_ptr); + const arg = try testLocal(&store, .u64); + const temporary = try testLocal(&store, aggregate); + const temporary_alias = try testLocal(&store, aggregate); + const store_unit = try testLocal(&store, .zst); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = store_unit } }); + const ptr_store = try testLowLevel(&store, store_unit, .ptr_store, &.{ destination, temporary_alias }, ret); + const alias = try store.addCFStmt(.{ .assign_ref = .{ + .target = temporary_alias, + .op = .{ .local = temporary }, + .next = ptr_store, + } }); + const call = try store.addCFStmt(.{ .assign_call = .{ + .target = temporary, + .proc = callee, + .args = try store.addLocalSpan(&.{arg}), + .next = alias, + } }); + const caller = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{ destination, arg }), + .frame_locals = try store.addLocalSpan(&.{ destination, arg, temporary, temporary_alias, store_unit }), + .body = call, + .ret_layout = .zst, + }); + + try run(&store, &layouts); + + const rewritten = store.getCFStmt(call).assign_call; + try std.testing.expect(rewritten.proc != callee); + try std.testing.expectEqual(store_unit, rewritten.target); + try std.testing.expectEqual(ret, rewritten.next); + const rewritten_args = store.getLocalSpan(rewritten.args); + try std.testing.expectEqual(@as(usize, 2), rewritten_args.len); + try std.testing.expectEqual(destination, GuardedList.at(rewritten_args, 0)); + try std.testing.expectEqual(arg, GuardedList.at(rewritten_args, 1)); + + const variant = store.getProcSpec(rewritten.proc); + try std.testing.expectEqual(layout_mod.Idx.zst, variant.ret_layout); + const variant_args = store.getLocalSpan(variant.args); + try std.testing.expectEqual(@as(usize, 2), variant_args.len); + const variant_dest = GuardedList.at(variant_args, 0); + const variant_value = GuardedList.at(variant_args, 1); + try std.testing.expectEqual(aggregate_ptr, store.getLocal(variant_dest).layout_idx); + try std.testing.expectEqual(layout_mod.Idx.u64, store.getLocal(variant_value).layout_idx); + + const variant_store = store.getCFStmt(variant.body.?).store_struct; + try std.testing.expectEqual(variant_dest, variant_store.dest); + try std.testing.expectEqual(aggregate, variant_store.struct_layout); + const variant_store_fields = store.getLocalSpan(variant_store.fields); + try std.testing.expectEqual(@as(usize, 2), variant_store_fields.len); + try std.testing.expectEqual(variant_value, GuardedList.at(variant_store_fields, 0)); + try std.testing.expectEqual(variant_value, GuardedList.at(variant_store_fields, 1)); + try std.testing.expectEqual(layout_mod.Idx.zst, store.getLocal(store.getCFStmt(variant_store.next).ret.value).layout_idx); + + const caller_proc = store.getProcSpec(caller); + try std.testing.expectEqual(call, caller_proc.body.?); +} + +test "return slot lowers direct tag return into destination store" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout_mod.Store.init(allocator, @import("base").target.TargetUsize.native); + defer layouts.deinit(); + + const aggregate = try testTagLayout(&layouts); + const aggregate_ptr = try layouts.insertPtr(aggregate); + const callee = try testTagCallee(&store, aggregate); + + const destination = try testLocal(&store, aggregate_ptr); + const arg = try testLocal(&store, .u64); + const temporary = try testLocal(&store, aggregate); + const store_unit = try testLocal(&store, .zst); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = store_unit } }); + const ptr_store = try testLowLevel(&store, store_unit, .ptr_store, &.{ destination, temporary }, ret); + const call = try store.addCFStmt(.{ .assign_call = .{ + .target = temporary, + .proc = callee, + .args = try store.addLocalSpan(&.{arg}), + .next = ptr_store, + } }); + _ = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{ destination, arg }), + .frame_locals = try store.addLocalSpan(&.{ destination, arg, temporary, store_unit }), + .body = call, + .ret_layout = .zst, + }); + + try run(&store, &layouts); + + const rewritten = store.getCFStmt(call).assign_call; + try std.testing.expect(rewritten.proc != callee); + const variant = store.getProcSpec(rewritten.proc); + const variant_args = store.getLocalSpan(variant.args); + + const variant_store = store.getCFStmt(variant.body.?).store_tag; + try std.testing.expectEqual(GuardedList.at(variant_args, 0), variant_store.dest); + try std.testing.expectEqual(aggregate, variant_store.tag_layout); + try std.testing.expectEqual(@as(u16, 0), variant_store.variant_index); + try std.testing.expectEqual(@as(u16, 0), variant_store.discriminant); + try std.testing.expectEqual(GuardedList.at(variant_args, 1), variant_store.payload.?); + try std.testing.expectEqual(layout_mod.Idx.zst, store.getLocal(store.getCFStmt(variant_store.next).ret.value).layout_idx); +} + +test "return slot shares one variant for identical proc and layout demands" { + const allocator = std.testing.allocator; + var store = LirStore.init(allocator); + defer store.deinit(); + var layouts = try layout_mod.Store.init(allocator, @import("base").target.TargetUsize.native); + defer layouts.deinit(); + + const aggregate = try testStructLayout(&layouts); + const aggregate_ptr = try layouts.insertPtr(aggregate); + const callee = try testAggregateCallee(&store, aggregate); + + const destination_a = try testLocal(&store, aggregate_ptr); + const destination_b = try testLocal(&store, aggregate_ptr); + const arg = try testLocal(&store, .u64); + const temporary_a = try testLocal(&store, aggregate); + const temporary_b = try testLocal(&store, aggregate); + const store_unit_a = try testLocal(&store, .zst); + const store_unit_b = try testLocal(&store, .zst); + + const ret = try store.addCFStmt(.{ .ret = .{ .value = store_unit_b } }); + const ptr_store_b = try testLowLevel(&store, store_unit_b, .ptr_store, &.{ destination_b, temporary_b }, ret); + const call_b = try store.addCFStmt(.{ .assign_call = .{ + .target = temporary_b, + .proc = callee, + .args = try store.addLocalSpan(&.{arg}), + .next = ptr_store_b, + } }); + const ptr_store_a = try testLowLevel(&store, store_unit_a, .ptr_store, &.{ destination_a, temporary_a }, call_b); + const call_a = try store.addCFStmt(.{ .assign_call = .{ + .target = temporary_a, + .proc = callee, + .args = try store.addLocalSpan(&.{arg}), + .next = ptr_store_a, + } }); + _ = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .args = try store.addLocalSpan(&.{ destination_a, destination_b, arg }), + .frame_locals = try store.addLocalSpan(&.{ destination_a, destination_b, arg, temporary_a, temporary_b, store_unit_a, store_unit_b }), + .body = call_a, + .ret_layout = .zst, + }); + + const before_proc_count = store.procSpecCount(); + try run(&store, &layouts); + + const rewritten_a = store.getCFStmt(call_a).assign_call; + const rewritten_b = store.getCFStmt(call_b).assign_call; + try std.testing.expectEqual(rewritten_a.proc, rewritten_b.proc); + try std.testing.expectEqual(@as(usize, before_proc_count + 1), store.procSpecCount()); +} diff --git a/src/lir/scalarize_joins.zig b/src/lir/scalarize_joins.zig index 1fca9c7f31f..049dcd52bb3 100644 --- a/src/lir/scalarize_joins.zig +++ b/src/lir/scalarize_joins.zig @@ -238,7 +238,7 @@ const Pass = struct { for (0..arms.len) |index| try self.stack.append(self.allocator, GuardedList.at(arms, index).on_match); try self.stack.append(self.allocator, s.on_miss); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try self.stack.append(self.allocator, s.next); }, .jump, .ret, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, @@ -523,7 +523,7 @@ const Pass = struct { try self.stack.append(self.allocator, j.body); try self.stack.append(self.allocator, j.remainder); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |*s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |*s| { s.next = self.resolveRemoved(s.next); try self.stack.append(self.allocator, s.next); }, @@ -582,7 +582,7 @@ const Pass = struct { try self.stack.append(self.allocator, join_stmt.body); try self.stack.append(self.allocator, join_stmt.remainder); }, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |a| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |a| { try self.stack.append(self.allocator, a.next); }, .jump, .ret, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, @@ -632,6 +632,7 @@ const Pass = struct { }, .assign_packed_erased_fn => |assign| { if (assign.capture) |capture| try self.noteUse(capture); + if (assign.reuse) |reuse| try self.noteUse(reuse); try self.noteWrite(assign.target); try self.stack.append(self.allocator, assign.next); }, @@ -657,6 +658,17 @@ const Pass = struct { try self.noteWrite(assign.target); try self.stack.append(self.allocator, assign.next); }, + .store_struct => |assign| { + try self.noteUse(assign.dest); + const fields = self.store.getLocalSpan(assign.fields); + for (0..fields.len) |index| try self.noteUse(GuardedList.at(fields, index)); + try self.stack.append(self.allocator, assign.next); + }, + .store_tag => |assign| { + try self.noteUse(assign.dest); + if (assign.payload) |payload| try self.noteUse(payload); + try self.stack.append(self.allocator, assign.next); + }, .set_local => |assign| { if (assign.mode == .initialize_join_param) { const entry = try self.init_writes.getOrPut(assign.target); diff --git a/src/lir/str_append.zig b/src/lir/str_append.zig new file mode 100644 index 00000000000..0a38498e280 --- /dev/null +++ b/src/lir/str_append.zig @@ -0,0 +1,664 @@ +//! Creates internal append-into-string variants for direct string producers. +//! +//! This runs before ARC. It consumes this explicit caller shape: +//! +//! ```text +//! result = call(args...) +//! out = str_concat(acc, result) +//! ``` +//! +//! and rewrites it to: +//! +//! ```text +//! out = call_append(acc, args...) +//! ``` +//! +//! The generated variant has the ordinary explicit signature: +//! +//! ```text +//! call_append(acc: Str, args...) -> Str +//! ``` +//! +//! If the source proc directly returns `Str.concat(left, right)`, the append +//! variant builds `Str.concat(Str.concat(acc, left), right)` so the source +//! proc's intermediate returned string is not materialized. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const core = @import("lir_core"); +const layout_mod = @import("layout"); + +const LIR = core.LIR; +const LirStore = core.LirStore; +const GuardedList = LirStore.GuardedList; +const CFStmtId = LIR.CFStmtId; +const LocalId = LIR.LocalId; +const LowLevelOp = LIR.LowLevel; + +/// Allocation failure raised while rewriting string append statements. +pub const ResourceError = Allocator.Error; + +/// Rewrite string append statements to direct helper procedure calls. +pub fn run(store: *LirStore) ResourceError!void { + var pass = StrAppendPass{ + .store = store, + .variants = std.AutoHashMap(VariantKey, LIR.LirProcSpecId).init(store.allocator), + }; + defer pass.variants.deinit(); + + const proc_count = store.procSpecCount(); + var proc_index: usize = 0; + while (proc_index < proc_count) : (proc_index += 1) { + try pass.transformProc(@enumFromInt(proc_index)); + } +} + +const VariantKey = struct { + source: LIR.LirProcSpecId, +}; + +const StrAppendPass = struct { + store: *LirStore, + variants: std.AutoHashMap(VariantKey, LIR.LirProcSpecId), + + fn transformProc(self: *StrAppendPass, proc_id: LIR.LirProcSpecId) ResourceError!void { + const proc = self.store.getProcSpec(proc_id); + if (proc.body == null or proc.hosted != null or proc.abi != .roc) return; + + var work = std.ArrayList(CFStmtId).empty; + defer work.deinit(self.store.allocator); + var visited = std.AutoHashMap(CFStmtId, void).init(self.store.allocator); + defer visited.deinit(); + + try work.append(self.store.allocator, proc.body.?); + while (work.pop()) |stmt_id| { + const entry = try visited.getOrPut(stmt_id); + if (entry.found_existing) continue; + + _ = try self.rewriteAt(stmt_id); + try self.appendSuccessors(&work, stmt_id); + } + } + + fn rewriteAt(self: *StrAppendPass, call_stmt_id: CFStmtId) ResourceError!bool { + const call_stmt = switch (self.store.getCFStmt(call_stmt_id)) { + .assign_call => |s| s, + else => return false, + }; + + if (!isStrLayout(self.store.getLocal(call_stmt.target).layout_idx)) return false; + + const callee = self.store.getProcSpec(call_stmt.proc); + if (callee.body == null or callee.hosted != null or callee.abi != .roc) return false; + if (callee.ret_layout != .str) return false; + + const concat = try self.findConcatAfterCall(call_stmt.target, call_stmt.next) orelse return false; + const concat_stmt_id = concat.stmt; + const concat_stmt = switch (self.store.getCFStmt(concat_stmt_id)) { + .assign_low_level => |s| s, + else => return false, + }; + if (!isStrLayout(self.store.getLocal(concat_stmt.target).layout_idx)) return false; + + if (!isStrLayout(self.store.getLocal(concat.accumulator).layout_idx)) return false; + + const variant = try self.appendVariant(call_stmt.proc); + + var args = std.ArrayList(LocalId).empty; + defer args.deinit(self.store.allocator); + try args.append(self.store.allocator, concat.accumulator); + const call_args = self.store.getLocalSpan(call_stmt.args); + for (0..call_args.len) |index| { + try args.append(self.store.allocator, GuardedList.at(call_args, index)); + } + + self.store.getCFStmtPtr(call_stmt_id).* = .{ .assign_call = .{ + .target = concat_stmt.target, + .proc = variant, + .args = try self.store.addLocalSpan(args.items), + .is_cold = call_stmt.is_cold, + .next = concat_stmt.next, + } }; + + return true; + } + + const ConcatAfterCall = struct { + stmt: CFStmtId, + accumulator: LocalId, + }; + + fn findConcatAfterCall(self: *StrAppendPass, source: LocalId, first_stmt: CFStmtId) ResourceError!?ConcatAfterCall { + var aliases = std.AutoHashMap(LocalId, LocalId).init(self.store.allocator); + defer aliases.deinit(); + + var current = first_stmt; + while (true) { + switch (self.store.getCFStmt(current)) { + .assign_ref => |stmt| switch (stmt.op) { + .local => |local| { + if (self.store.getLocal(stmt.target).layout_idx != self.store.getLocal(local).layout_idx) return null; + try aliases.put(stmt.target, resolveAlias(&aliases, local)); + current = stmt.next; + continue; + }, + else => return null, + }, + .assign_low_level => |stmt| { + if (stmt.op != .str_concat) return null; + const args = self.store.getLocalSpan(stmt.args); + if (args.len != 2) return null; + if (resolveAlias(&aliases, GuardedList.at(args, 1)) != source) return null; + return .{ + .stmt = current, + .accumulator = resolveAlias(&aliases, GuardedList.at(args, 0)), + }; + }, + else => return null, + } + } + } + + fn resolveAlias(aliases: *const std.AutoHashMap(LocalId, LocalId), local: LocalId) LocalId { + var current = local; + while (aliases.get(current)) |next| { + current = next; + } + return current; + } + + fn appendVariant(self: *StrAppendPass, source: LIR.LirProcSpecId) ResourceError!LIR.LirProcSpecId { + const key = VariantKey{ .source = source }; + if (self.variants.get(key)) |variant| return variant; + + const variant = try self.createAppendVariant(source); + try self.variants.put(key, variant); + return variant; + } + + fn createAppendVariant(self: *StrAppendPass, source: LIR.LirProcSpecId) ResourceError!LIR.LirProcSpecId { + const source_spec = self.store.getProcSpec(source); + const source_body = source_spec.body orelse unreachable; + const source_args = self.store.getLocalSpan(source_spec.args); + + const accumulator = try self.store.addLocal(.{ .layout_idx = .str }); + + var variant_args = try std.ArrayList(LocalId).initCapacity(self.store.allocator, source_args.len + 1); + defer variant_args.deinit(self.store.allocator); + variant_args.appendAssumeCapacity(accumulator); + + for (0..source_args.len) |index| { + const source_arg = GuardedList.at(source_args, index); + const arg = try self.store.addLocal(.{ .layout_idx = self.store.getLocal(source_arg).layout_idx }); + variant_args.appendAssumeCapacity(arg); + } + + var cloner = try BodyCloner.init(self.store, accumulator); + defer cloner.deinit(); + + for (0..source_args.len) |index| { + const source_arg = GuardedList.at(source_args, index); + const variant_arg = variant_args.items[index + 1]; + cloner.local_map[@intFromEnum(source_arg)] = variant_arg; + } + + const source_frame = self.store.getLocalSpan(source_spec.frame_locals); + try cloner.new_locals.appendSlice(self.store.allocator, variant_args.items); + for (0..source_frame.len) |index| { + _ = try cloner.mapLocal(GuardedList.at(source_frame, index)); + } + + const body = try cloner.cloneStmt(source_body); + + var frame_locals = try std.ArrayList(LocalId).initCapacity(self.store.allocator, cloner.new_locals.items.len); + defer frame_locals.deinit(self.store.allocator); + frame_locals.appendSliceAssumeCapacity(cloner.new_locals.items); + std.mem.sort(LocalId, frame_locals.items, {}, localIdLessThan); + const unique_len = uniqueSortedLocals(frame_locals.items); + + const variant = try self.store.addProcSpec(.{ + .name = self.store.freshSyntheticSymbol(), + .args = try self.store.addLocalSpan(variant_args.items), + .frame_locals = try self.store.addLocalSpan(frame_locals.items[0..unique_len]), + .body = body, + .ret_layout = .str, + .abi = .roc, + }); + try self.store.copyProcDebugInfo(variant, source); + + return variant; + } + + fn appendSuccessors( + self: *StrAppendPass, + work: *std.ArrayList(CFStmtId), + stmt_id: CFStmtId, + ) ResourceError!void { + switch (self.store.getCFStmt(stmt_id)) { + inline .assign_ref, + .assign_literal, + .init_uninitialized, + .assign_call, + .assign_call_erased, + .assign_packed_erased_fn, + .assign_low_level, + .assign_list, + .assign_struct, + .assign_tag, + .store_struct, + .store_tag, + .set_local, + .debug, + .expect, + .comptime_branch_taken, + .incref, + .decref, + .decref_if_initialized, + .free, + => |s| try work.append(self.store.allocator, s.next), + + .switch_stmt => |s| { + if (s.continuation) |continuation| try work.append(self.store.allocator, continuation); + try work.append(self.store.allocator, s.default_branch); + const branches = self.store.getCFSwitchBranches(s.branches); + for (0..branches.len) |index| { + try work.append(self.store.allocator, GuardedList.at(branches, index).body); + } + }, + .switch_initialized_payload => |s| { + try work.append(self.store.allocator, s.initialized_branch); + try work.append(self.store.allocator, s.uninitialized_branch); + }, + .str_match => |s| { + try work.append(self.store.allocator, s.on_match); + try work.append(self.store.allocator, s.on_miss); + }, + .str_match_set => |s| { + const arms = self.store.getStrMatchArms(s.arms); + for (0..arms.len) |index| { + try work.append(self.store.allocator, GuardedList.at(arms, index).on_match); + } + try work.append(self.store.allocator, s.on_miss); + }, + .join => |s| { + try work.append(self.store.allocator, s.body); + try work.append(self.store.allocator, s.remainder); + }, + .runtime_error, + .comptime_exhaustiveness_failed, + .expect_err, + .loop_continue, + .loop_break, + .jump, + .ret, + .crash, + => {}, + } + } + + fn isStrLayout(layout_idx: layout_mod.Idx) bool { + return layout_idx == .str; + } + + fn localIdLessThan(_: void, a: LocalId, b: LocalId) bool { + return @intFromEnum(a) < @intFromEnum(b); + } +}; + +const BodyCloner = struct { + store: *LirStore, + accumulator: LocalId, + local_map: []?LocalId, + stmt_map: std.AutoHashMap(CFStmtId, CFStmtId), + new_locals: std.ArrayList(LocalId), + + fn init(store: *LirStore, accumulator: LocalId) ResourceError!BodyCloner { + const local_map = try store.allocator.alloc(?LocalId, store.localCount()); + @memset(local_map, null); + return .{ + .store = store, + .accumulator = accumulator, + .local_map = local_map, + .stmt_map = std.AutoHashMap(CFStmtId, CFStmtId).init(store.allocator), + .new_locals = .empty, + }; + } + + fn deinit(self: *BodyCloner) void { + self.new_locals.deinit(self.store.allocator); + self.stmt_map.deinit(); + self.store.allocator.free(self.local_map); + } + + fn cloneStmt(self: *BodyCloner, old_id: CFStmtId) ResourceError!CFStmtId { + if (self.stmt_map.get(old_id)) |existing| return existing; + + const cloned = switch (self.store.getCFStmt(old_id)) { + .init_uninitialized => |s| try self.store.addCFStmt(.{ .init_uninitialized = .{ + .target = try self.mapLocal(s.target), + .next = try self.cloneStmt(s.next), + } }), + .assign_ref => |s| try self.store.addCFStmt(.{ .assign_ref = .{ + .target = try self.mapLocal(s.target), + .op = try self.mapRefOp(s.op), + .next = try self.cloneStmt(s.next), + } }), + .assign_literal => |s| try self.store.addCFStmt(.{ .assign_literal = .{ + .target = try self.mapLocal(s.target), + .value = s.value, + .next = try self.cloneStmt(s.next), + } }), + .assign_call => |s| try self.store.addCFStmt(.{ .assign_call = .{ + .target = try self.mapLocal(s.target), + .proc = s.proc, + .args = try self.mapLocalSpan(s.args), + .is_cold = s.is_cold, + .next = try self.cloneStmt(s.next), + } }), + .assign_call_erased => |s| try self.store.addCFStmt(.{ .assign_call_erased = .{ + .target = try self.mapLocal(s.target), + .closure = try self.mapLocal(s.closure), + .args = try self.mapLocalSpan(s.args), + .next = try self.cloneStmt(s.next), + } }), + .assign_packed_erased_fn => |s| try self.store.addCFStmt(.{ .assign_packed_erased_fn = .{ + .target = try self.mapLocal(s.target), + .proc = s.proc, + .capture = try self.mapMaybeLocal(s.capture), + .capture_layout = s.capture_layout, + .on_drop = s.on_drop, + .reuse = try self.mapMaybeLocal(s.reuse), + .reuse_unique = s.reuse_unique, + .next = try self.cloneStmt(s.next), + } }), + .assign_low_level => |s| if (s.op == .str_concat and self.directReturnOf(s.next, s.target)) + try self.cloneConcatReturn(s) + else + try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = try self.mapLocal(s.target), + .op = s.op, + .rc_effect = s.rc_effect, + .unique_args = s.unique_args, + .args = try self.mapLocalSpan(s.args), + .next = try self.cloneStmt(s.next), + } }), + .assign_list => |s| try self.store.addCFStmt(.{ .assign_list = .{ + .target = try self.mapLocal(s.target), + .elems = try self.mapLocalSpan(s.elems), + .next = try self.cloneStmt(s.next), + } }), + .assign_struct => |s| try self.store.addCFStmt(.{ .assign_struct = .{ + .target = try self.mapLocal(s.target), + .fields = try self.mapLocalSpan(s.fields), + .next = try self.cloneStmt(s.next), + } }), + .assign_tag => |s| try self.store.addCFStmt(.{ .assign_tag = .{ + .target = try self.mapLocal(s.target), + .variant_index = s.variant_index, + .discriminant = s.discriminant, + .payload = try self.mapMaybeLocal(s.payload), + .next = try self.cloneStmt(s.next), + } }), + .store_struct => |s| try self.store.addCFStmt(.{ .store_struct = .{ + .dest = try self.mapLocal(s.dest), + .struct_layout = s.struct_layout, + .fields = try self.mapLocalSpan(s.fields), + .next = try self.cloneStmt(s.next), + } }), + .store_tag => |s| try self.store.addCFStmt(.{ .store_tag = .{ + .dest = try self.mapLocal(s.dest), + .tag_layout = s.tag_layout, + .variant_index = s.variant_index, + .discriminant = s.discriminant, + .payload = try self.mapMaybeLocal(s.payload), + .next = try self.cloneStmt(s.next), + } }), + .set_local => |s| try self.store.addCFStmt(.{ .set_local = .{ + .target = try self.mapLocal(s.target), + .value = try self.mapLocal(s.value), + .mode = s.mode, + .next = try self.cloneStmt(s.next), + } }), + .debug => |s| try self.store.addCFStmt(.{ .debug = .{ + .message = try self.mapLocal(s.message), + .next = try self.cloneStmt(s.next), + } }), + .expect => |s| try self.store.addCFStmt(.{ .expect = .{ + .condition = try self.mapLocal(s.condition), + .next = try self.cloneStmt(s.next), + } }), + .expect_err => |s| try self.store.addCFStmt(.{ .expect_err = .{ + .message = try self.mapLocal(s.message), + .region = s.region, + } }), + .runtime_error => try self.store.addCFStmt(.runtime_error), + .comptime_exhaustiveness_failed => |s| try self.store.addCFStmt(.{ .comptime_exhaustiveness_failed = .{ + .site = s.site, + } }), + .comptime_branch_taken => |s| try self.store.addCFStmt(.{ .comptime_branch_taken = .{ + .site = s.site, + .branch_index = s.branch_index, + .next = try self.cloneStmt(s.next), + } }), + .incref => |s| try self.store.addCFStmt(.{ .incref = .{ + .value = try self.mapLocal(s.value), + .rc = s.rc, + .count = s.count, + .atomicity = s.atomicity, + .next = try self.cloneStmt(s.next), + } }), + .decref => |s| try self.store.addCFStmt(.{ .decref = .{ + .value = try self.mapLocal(s.value), + .rc = s.rc, + .atomicity = s.atomicity, + .next = try self.cloneStmt(s.next), + } }), + .decref_if_initialized => |s| try self.store.addCFStmt(.{ .decref_if_initialized = .{ + .cond = try self.mapLocal(s.cond), + .cond_mask = s.cond_mask, + .value = try self.mapLocal(s.value), + .rc = s.rc, + .atomicity = s.atomicity, + .next = try self.cloneStmt(s.next), + } }), + .free => |s| try self.store.addCFStmt(.{ .free = .{ + .value = try self.mapLocal(s.value), + .rc = s.rc, + .atomicity = s.atomicity, + .next = try self.cloneStmt(s.next), + } }), + .switch_stmt => |s| try self.cloneSwitch(s), + .switch_initialized_payload => |s| try self.store.addCFStmt(.{ .switch_initialized_payload = .{ + .cond = try self.mapLocal(s.cond), + .cond_mask = s.cond_mask, + .payload = try self.mapLocal(s.payload), + .uninitialized_is_cold = s.uninitialized_is_cold, + .initialized_branch = try self.cloneStmt(s.initialized_branch), + .uninitialized_branch = try self.cloneStmt(s.uninitialized_branch), + } }), + .str_match => |s| try self.store.addCFStmt(.{ .str_match = .{ + .source = try self.mapLocal(s.source), + .prefix = s.prefix, + .steps = try self.mapStrMatchSteps(s.steps), + .end = s.end, + .on_match = try self.cloneStmt(s.on_match), + .on_miss = try self.cloneStmt(s.on_miss), + } }), + .str_match_set => |s| try self.cloneStrMatchSet(s), + .loop_continue => try self.store.addCFStmt(.loop_continue), + .loop_break => try self.store.addCFStmt(.loop_break), + .join => |s| try self.store.addCFStmt(.{ .join = .{ + .id = s.id, + .params = try self.mapLocalSpan(s.params), + .maybe_uninitialized_params = try self.mapLocalSpan(s.maybe_uninitialized_params), + .maybe_uninitialized_conditions = try self.mapLocalSpan(s.maybe_uninitialized_conditions), + .maybe_uninitialized_condition_masks = s.maybe_uninitialized_condition_masks, + .body = try self.cloneStmt(s.body), + .remainder = try self.cloneStmt(s.remainder), + } }), + .jump => |s| try self.store.addCFStmt(.{ .jump = .{ .target = s.target } }), + .ret => |s| try self.cloneRet(s.value), + .crash => |s| try self.store.addCFStmt(.{ .crash = .{ .msg = s.msg } }), + }; + + try self.stmt_map.put(old_id, cloned); + return cloned; + } + + fn cloneRet(self: *BodyCloner, value: LocalId) ResourceError!CFStmtId { + const target = try self.addTemp(.str); + const ret_stmt = try self.store.addCFStmt(.{ .ret = .{ .value = target } }); + return try self.concatInto(target, self.accumulator, try self.mapLocal(value), ret_stmt); + } + + fn cloneConcatReturn(self: *BodyCloner, s: anytype) ResourceError!CFStmtId { + const args = self.store.getLocalSpan(s.args); + if (args.len != 2) return try self.cloneRet(s.target); + + const first_append = try self.addTemp(.str); + const final = try self.mapLocal(s.target); + const ret_stmt = try self.store.addCFStmt(.{ .ret = .{ .value = final } }); + const second = try self.concatInto(final, first_append, try self.mapLocal(GuardedList.at(args, 1)), ret_stmt); + return try self.concatInto(first_append, self.accumulator, try self.mapLocal(GuardedList.at(args, 0)), second); + } + + fn concatInto(self: *BodyCloner, target: LocalId, left: LocalId, right: LocalId, next: CFStmtId) ResourceError!CFStmtId { + return try self.store.addCFStmt(.{ .assign_low_level = .{ + .target = target, + .op = .str_concat, + .rc_effect = LowLevelOp.str_concat.rcEffect(), + .args = try self.store.addLocalSpan(&.{ left, right }), + .next = next, + } }); + } + + fn directReturnOf(self: *const BodyCloner, next: CFStmtId, value: LocalId) bool { + return switch (self.store.getCFStmt(next)) { + .ret => |ret_stmt| ret_stmt.value == value, + else => false, + }; + } + + fn cloneSwitch(self: *BodyCloner, s: anytype) ResourceError!CFStmtId { + const old_branches = self.store.getCFSwitchBranches(s.branches); + const branches = try self.store.allocator.alloc(LIR.CFSwitchBranch, old_branches.len); + defer self.store.allocator.free(branches); + for (0..old_branches.len) |index| { + const old = GuardedList.at(old_branches, index); + const new = &branches[index]; + new.* = .{ + .value = old.value, + .body = try self.cloneStmt(old.body), + }; + } + return try self.store.addCFStmt(.{ .switch_stmt = .{ + .cond = try self.mapLocal(s.cond), + .branches = try self.store.addCFSwitchBranches(branches), + .default_branch = try self.cloneStmt(s.default_branch), + .default_is_cold = s.default_is_cold, + .continuation = if (s.continuation) |continuation| try self.cloneStmt(continuation) else null, + } }); + } + + fn cloneStrMatchSet(self: *BodyCloner, s: anytype) ResourceError!CFStmtId { + const old_arms = self.store.getStrMatchArms(s.arms); + const arms = try self.store.allocator.alloc(LIR.StrMatchArm, old_arms.len); + defer self.store.allocator.free(arms); + for (0..old_arms.len) |index| { + const old = GuardedList.at(old_arms, index); + const new = &arms[index]; + new.* = .{ + .prefix = old.prefix, + .steps = try self.mapStrMatchSteps(old.steps), + .end = old.end, + .on_match = try self.cloneStmt(old.on_match), + }; + } + return try self.store.addCFStmt(.{ .str_match_set = .{ + .source = try self.mapLocal(s.source), + .arms = try self.store.addStrMatchArms(arms), + .on_miss = try self.cloneStmt(s.on_miss), + } }); + } + + fn mapStrMatchSteps(self: *BodyCloner, span: LIR.StrMatchStepSpan) ResourceError!LIR.StrMatchStepSpan { + const old_steps = self.store.getStrMatchSteps(span); + const steps = try self.store.allocator.alloc(LIR.StrMatchStep, old_steps.len); + defer self.store.allocator.free(steps); + for (0..old_steps.len) |index| { + const old = GuardedList.at(old_steps, index); + const new = &steps[index]; + new.* = old; + new.capture = switch (old.capture) { + .discard => .discard, + .view => |local| .{ .view = try self.mapLocal(local) }, + }; + } + return try self.store.addStrMatchSteps(steps); + } + + fn mapRefOp(self: *BodyCloner, op: LIR.RefOp) ResourceError!LIR.RefOp { + return switch (op) { + .local => |local| .{ .local = try self.mapLocal(local) }, + .discriminant => |d| .{ .discriminant = .{ .source = try self.mapLocal(d.source) } }, + .field => |f| .{ .field = .{ + .source = try self.mapLocal(f.source), + .field_idx = f.field_idx, + } }, + .tag_payload => |t| .{ .tag_payload = .{ + .source = try self.mapLocal(t.source), + .payload_idx = t.payload_idx, + .variant_index = t.variant_index, + .tag_discriminant = t.tag_discriminant, + } }, + .tag_payload_struct => |t| .{ .tag_payload_struct = .{ + .source = try self.mapLocal(t.source), + .variant_index = t.variant_index, + .tag_discriminant = t.tag_discriminant, + } }, + .list_reinterpret => |l| .{ .list_reinterpret = .{ .backing_ref = try self.mapLocal(l.backing_ref) } }, + .nominal => |n| .{ .nominal = .{ .backing_ref = try self.mapLocal(n.backing_ref) } }, + }; + } + + fn mapLocalSpan(self: *BodyCloner, span: LIR.LocalSpan) ResourceError!LIR.LocalSpan { + const old_locals = self.store.getLocalSpan(span); + const locals = try self.store.allocator.alloc(LocalId, old_locals.len); + defer self.store.allocator.free(locals); + for (0..old_locals.len) |index| { + locals[index] = try self.mapLocal(GuardedList.at(old_locals, index)); + } + return try self.store.addLocalSpan(locals); + } + + fn mapMaybeLocal(self: *BodyCloner, maybe: ?LocalId) ResourceError!?LocalId { + return if (maybe) |local| try self.mapLocal(local) else null; + } + + fn mapLocal(self: *BodyCloner, old: LocalId) ResourceError!LocalId { + const index = @intFromEnum(old); + if (index >= self.local_map.len) unreachable; + if (self.local_map[index]) |existing| return existing; + + const fresh = try self.store.addLocal(.{ .layout_idx = self.store.getLocal(old).layout_idx }); + self.local_map[index] = fresh; + try self.new_locals.append(self.store.allocator, fresh); + return fresh; + } + + fn addTemp(self: *BodyCloner, layout_idx: layout_mod.Idx) ResourceError!LocalId { + const local = try self.store.addLocal(.{ .layout_idx = layout_idx }); + try self.new_locals.append(self.store.allocator, local); + return local; + } +}; + +fn uniqueSortedLocals(items: []LocalId) usize { + var unique_len: usize = 0; + for (items, 0..) |local, idx| { + if (idx > 0 and items[unique_len - 1] == local) continue; + items[unique_len] = local; + unique_len += 1; + } + return unique_len; +} diff --git a/src/lir/tag_reachability.zig b/src/lir/tag_reachability.zig index 445c24505c6..fdce15cfb4f 100644 --- a/src/lir/tag_reachability.zig +++ b/src/lir/tag_reachability.zig @@ -346,6 +346,8 @@ const Pass = struct { } try self.pushStmt(s.next); }, + .store_struct => |s| try self.pushStmt(s.next), + .store_tag => |s| try self.pushStmt(s.next), .set_local => |s| { if (try self.localInfoMut(s.target).mergeFrom(self.allocator, self.localInfo(s.value))) changed = true; try self.pushStmt(s.next); @@ -458,7 +460,10 @@ const Pass = struct { const args = self.store.getLocalSpan(s.args); for (0..args.len) |index| self.noteUse(GuardedList.at(args, index)); }, - .assign_packed_erased_fn => |s| if (s.capture) |capture| self.noteUse(capture), + .assign_packed_erased_fn => |s| { + if (s.capture) |capture| self.noteUse(capture); + if (s.reuse) |reuse| self.noteUse(reuse); + }, .assign_low_level => |s| { const args = self.store.getLocalSpan(s.args); for (0..args.len) |index| self.noteUse(GuardedList.at(args, index)); @@ -472,6 +477,15 @@ const Pass = struct { for (0..fields.len) |index| self.noteUse(GuardedList.at(fields, index)); }, .assign_tag => |s| if (s.payload) |payload| self.noteUse(payload), + .store_struct => |s| { + self.noteUse(s.dest); + const fields = self.store.getLocalSpan(s.fields); + for (0..fields.len) |index| self.noteUse(GuardedList.at(fields, index)); + }, + .store_tag => |s| { + self.noteUse(s.dest); + if (s.payload) |payload| self.noteUse(payload); + }, .set_local => |s| self.noteUse(s.value), .debug => |s| self.noteUse(s.message), .expect => |s| self.noteUse(s.condition), @@ -607,6 +621,8 @@ const Pass = struct { .assign_list => |*s| s.next = self.resolveRedirect(s.next), .assign_struct => |*s| s.next = self.resolveRedirect(s.next), .assign_tag => |*s| s.next = self.resolveRedirect(s.next), + .store_struct => |*s| s.next = self.resolveRedirect(s.next), + .store_tag => |*s| s.next = self.resolveRedirect(s.next), .set_local => |*s| s.next = self.resolveRedirect(s.next), .debug => |*s| s.next = self.resolveRedirect(s.next), .expect => |*s| s.next = self.resolveRedirect(s.next), diff --git a/src/lir/trmc.zig b/src/lir/trmc.zig index 7b70146f417..317184bfbe3 100644 --- a/src/lir/trmc.zig +++ b/src/lir/trmc.zig @@ -442,7 +442,7 @@ const Detection = struct { } }, .jump, .ret, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try work.append(gpa, .{ .stmt = s.next, .edge = .{ .stmt_next = item.stmt } }); }, } @@ -507,7 +507,7 @@ const Detection = struct { try self.appendSharedSuccessor(work, s.on_miss); }, .jump, .ret, .crash, .expect_err, .runtime_error, .comptime_exhaustiveness_failed, .loop_continue, .loop_break => {}, - inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { + inline .assign_ref, .assign_literal, .init_uninitialized, .assign_call, .assign_call_erased, .assign_packed_erased_fn, .assign_low_level, .assign_list, .assign_struct, .assign_tag, .store_struct, .store_tag, .set_local, .debug, .expect, .comptime_branch_taken, .incref, .decref, .decref_if_initialized, .free => |s| { try self.appendSharedSuccessor(work, s.next); }, } @@ -704,6 +704,8 @@ const Detection = struct { .assign_list => |s| self.spanTouchesChain(s.elems, c), .assign_struct => |s| self.spanTouchesChain(s.fields, c), .assign_tag => |s| if (s.payload) |payload| c.chainContains(payload) else false, + .store_struct => |s| c.chainContains(s.dest) or self.spanTouchesChain(s.fields, c), + .store_tag => |s| c.chainContains(s.dest) or if (s.payload) |payload| c.chainContains(payload) else false, // Reading the value is a use; overwriting a tracked local would // corrupt the chain, so treat that as disqualifying too. .set_local => |s| c.chainContains(s.value) or c.chainContains(s.target), diff --git a/src/llvm_compile/compile.zig b/src/llvm_compile/compile.zig index b8f23692c29..8f6c4b89c3c 100644 --- a/src/llvm_compile/compile.zig +++ b/src/llvm_compile/compile.zig @@ -120,9 +120,11 @@ pub const CompileOptions = struct { /// builtin bitcode payload before retargeting the merged LLVM module. target_ptr_width_bits: u8, /// Treat the target as freestanding for LLVM object emission: optimization - /// cannot assume target library functions, and memory intrinsics are lowered - /// to explicit loops before codegen. + /// cannot assume target library functions. no_target_libcalls: bool = false, + /// Lower LLVM memory intrinsics to explicit loops before codegen. Callers set + /// this for targets that cannot use libcalls or native memory operations. + lower_memory_intrinsics_to_loops: bool = false, }; fn valueName(value: *bindings.Value) []const u8 { @@ -183,6 +185,7 @@ const core_builtin_roots = std.StaticStringMap(void).initComptime(.{ .{ "roc_builtins_num_mul_with_overflow_i128", {} }, .{ "roc_builtins_num_mul_with_overflow_u128", {} }, .{ "roc_builtins_allocate_with_refcount", {} }, + .{ "roc_builtins_box_prepare_update", {} }, .{ "roc_builtins_box_decref_with", {} }, .{ "roc_builtins_box_decref_with_single_thread", {} }, .{ "roc_builtins_box_free_with", {} }, @@ -571,6 +574,7 @@ fn emitMergedBitcodeToObjectFile( .bitcode_filename = null, .coverage = default_coverage, .no_target_libcalls = options.no_target_libcalls, + .lower_memory_intrinsics_to_loops = options.lower_memory_intrinsics_to_loops, }; // Emit merged module to object file @@ -632,6 +636,7 @@ pub fn compileToSharedLibrary(allocator: Allocator, io: std.Io, bitcode: []const .macos, .windows => false, else => true, }; + pic_options.lower_memory_intrinsics_to_loops = pic_options.no_target_libcalls; try emitMergedBitcodeToObjectFile(allocator, io, bitcode, pic_options, object_path); diff --git a/src/machine_code_shim/main.zig b/src/machine_code_shim/main.zig index 1e1690a2985..ebdd96b03c5 100644 --- a/src/machine_code_shim/main.zig +++ b/src/machine_code_shim/main.zig @@ -267,6 +267,12 @@ fn loadDevProgram( }, } } + for (view.data_relocations) |record| { + const name = try view.symbolName(record.symbol); + if (resolveShimFunction(name)) |target_addr| { + try ensureFunctionStub(gpa, &function_stubs, name, target_addr); + } + } const stub_size = try jumpStubSize(); if (function_stubs.items.len > view.function_stubs.len / stub_size) { @@ -294,6 +300,7 @@ fn loadDevProgram( &relocation_context, RelocationContext.resolve, ); + try applyDataRelocations(view, &relocation_context); try finishDirectImageRelocation(view); return .{ @@ -322,6 +329,34 @@ fn createDevProgram( return program; } +fn applyDataRelocations( + view: *const RunImage.ProgramView, + relocation_context: *const RelocationContext, +) LoadDevProgramError!void { + for (view.data_relocations) |record| { + if ((try record.relocationKind()) != .linked_data_abs64) return error.InvalidDevRunImage; + const name = try view.symbolName(record.symbol); + const target_addr = RelocationContext.resolve(relocation_context, name) orelse return error.UnresolvedSymbol; + const value = try relocatedDataAddress(target_addr, record.addend); + if (record.data_offset > std.math.maxInt(usize)) return error.InvalidDevRunImage; + const offset: usize = @intCast(record.data_offset); + if (offset > view.data.len or @sizeOf(usize) > view.data.len - offset) return error.InvalidDevRunImage; + std.mem.writeInt(usize, view.data[offset..][0..@sizeOf(usize)], value, .little); + } +} + +fn relocatedDataAddress(base_addr: usize, addend: i64) RunImage.ImageError!usize { + if (addend >= 0) { + const positive: usize = @intCast(addend); + if (positive > std.math.maxInt(usize) - base_addr) return error.InvalidDevRunImage; + return base_addr + positive; + } + if (addend == std.math.minInt(i64)) return error.InvalidDevRunImage; + const negative: usize = @intCast(-addend); + if (negative > base_addr) return error.InvalidDevRunImage; + return base_addr - negative; +} + fn destroyDevProgram(gpa: Allocator, program: *DevProgram) void { program.deinit(); gpa.destroy(program); @@ -772,3 +807,56 @@ test "loaded dev program borrows direct shared image metadata" { try std.testing.expectEqual(@as(u32, 0), program.entrypoints[0].ordinal); try std.testing.expectEqual(@as(u64, 0), program.entrypoints[0].code_offset); } + +test "data relocations patch data pointers" { + var data = [_]u8{0} ** (@sizeOf(usize) + 4); + const source_name = "roc__source"; + const target_name = "roc__target"; + const symbol_names = source_name ++ target_name; + const data_symbols = [_]RunImage.DataSymbol{ + .{ + .name = .{ .offset = 0, .len = source_name.len }, + .data_offset = 0, + .len = @sizeOf(usize), + .symbol_offset = 0, + .alignment = @alignOf(usize), + }, + .{ + .name = .{ .offset = source_name.len, .len = target_name.len }, + .data_offset = @sizeOf(usize), + .len = data.len - @sizeOf(usize), + .symbol_offset = 1, + .alignment = 1, + }, + }; + const data_relocations = [_]RunImage.DataRelocationRecord{ + .{ + .data_offset = 0, + .symbol = .{ .offset = source_name.len, .len = target_name.len }, + .addend = 2, + .kind = @intFromEnum(RunImage.RelocationKind.linked_data_abs64), + }, + }; + const view = RunImage.ProgramView{ + .executable = &.{}, + .code = &.{}, + .function_stubs = &.{}, + .entrypoints = &.{}, + .relocations = &.{}, + .data_relocations = &data_relocations, + .symbol_names = symbol_names, + .data = &data, + .data_symbols = &data_symbols, + .page_size = 4096, + }; + const relocation_context = RelocationContext{ + .view = &view, + .function_stubs = &.{}, + .code_base = 0, + }; + + try applyDataRelocations(&view, &relocation_context); + + const expected = @intFromPtr(data[0..].ptr) + @sizeOf(usize) + 1 + 2; + try std.testing.expectEqual(expected, std.mem.readInt(usize, data[0..@sizeOf(usize)], .little)); +} diff --git a/src/parse/AST.zig b/src/parse/AST.zig index 5c3ac845d7d..6f27255eb92 100644 --- a/src/parse/AST.zig +++ b/src/parse/AST.zig @@ -2663,6 +2663,7 @@ pub const WhereClause = union(enum) { name_tok: Token.Idx, args: Collection.Idx, ret_anno: TypeAnno.Idx, + effectful: bool, region: TokenizedRegion, }, @@ -2702,6 +2703,7 @@ pub const WhereClause = union(enum) { // remove preceding dot const method_name = ast.resolve(m.name_tok)[1..]; try tree.pushStringPair("name", method_name); + try tree.pushBoolPair("effectful", m.effectful); const attrs = tree.beginNode(); const args_begin = tree.beginNode(); diff --git a/src/parse/NodeStore.zig b/src/parse/NodeStore.zig index c0ac5d63560..07baf2e868d 100644 --- a/src/parse/NodeStore.zig +++ b/src/parse/NodeStore.zig @@ -1242,6 +1242,7 @@ pub fn addWhereClause(store: *NodeStore, clause: AST.WhereClause) std.mem.Alloca try store.extra_data.append(store.gpa, c.name_tok); try store.extra_data.append(store.gpa, @intFromEnum(c.args)); try store.extra_data.append(store.gpa, @intFromEnum(c.ret_anno)); + try store.extra_data.append(store.gpa, @intFromBool(c.effectful)); node.data.lhs = @intCast(ed_start); }, .mod_alias => |c| { @@ -2313,12 +2314,14 @@ pub fn getWhereClause(store: *const NodeStore, where_clause_idx: AST.WhereClause const name_tok = store.extra_data.items[ed_start]; const args = store.extra_data.items[ed_start + 1]; const ret_anno = store.extra_data.items[ed_start + 2]; + const effectful = store.extra_data.items[ed_start + 3] != 0; return .{ .mod_method = .{ .region = node.region, .var_tok = node.main_token, .name_tok = name_tok, .args = @enumFromInt(args), .ret_anno = @enumFromInt(ret_anno), + .effectful = effectful, } }; }, .where_mod_alias => { diff --git a/src/parse/Parser.zig b/src/parse/Parser.zig index 0f00ed1612a..4bed843bea8 100644 --- a/src/parse/Parser.zig +++ b/src/parse/Parser.zig @@ -4639,6 +4639,7 @@ fn runExprStatementKernel( .var_tok = state.var_tok, .args = args, .ret_anno = fn_type.ret, + .effectful = fn_type.effectful, } })); continue :expr_kernel .where_after_clause; } @@ -4653,6 +4654,7 @@ fn runExprStatementKernel( .var_tok = state.var_tok, .args = empty_args, .ret_anno = completed, + .effectful = false, } })); continue :expr_kernel .where_after_clause; }, diff --git a/src/postcheck/common.zig b/src/postcheck/common.zig index 30320732b05..17635d33935 100644 --- a/src/postcheck/common.zig +++ b/src/postcheck/common.zig @@ -27,9 +27,14 @@ pub const RootRequests = struct { /// Checked const data that must produce a runtime layout and callable entries. pub const StaticDataRequest = struct { - data: checked.ProvidedDataExport, + const_locator: checked.ConstLocator, + node: ?checked.ConstNodeId = null, + checked_type: checked.CheckedTypeId, }; +/// Stage-local readonly static-data value id. +pub const StaticDataId = enum(u32) { _ }; + /// Target settings carried through post-check lowering. pub const Target = struct { target_usize: base.target.TargetUsize = base.target.TargetUsize.native, diff --git a/src/postcheck/lambda_mono/ast.zig b/src/postcheck/lambda_mono/ast.zig index d2ad3f21a56..e1d11da2ddc 100644 --- a/src/postcheck/lambda_mono/ast.zig +++ b/src/postcheck/lambda_mono/ast.zig @@ -197,6 +197,13 @@ pub const Expr = struct { data: ExprData, }; +/// A restored compile-time value that may lower to static data once the final +/// LIR const plan and target layout are known. +pub const StaticDataCandidate = struct { + static_data: Common.StaticDataId, + runtime_expr: ExprId, +}; + /// Lambda Mono expression forms. pub const ExprData = union(enum) { local: LocalId, @@ -207,6 +214,7 @@ pub const ExprData = union(enum) { dec_lit: builtins.dec.RocDec, str_lit: StringLiteralId, bytes_lit: StringLiteralId, + static_data_candidate: StaticDataCandidate, list: Span(ExprId), tuple: Span(ExprId), record: Span(FieldExpr), @@ -415,6 +423,9 @@ pub const RuntimeSchemaRequest = struct { ty: Type.TypeId, }; +/// Request to make a Lambda Mono value available as static data. +pub const StaticDataValue = Common.StaticDataRequest; + /// Complete Lambda Mono program plus side arrays. pub const Program = struct { allocator: std.mem.Allocator, @@ -440,6 +451,7 @@ pub const Program = struct { roots: ProgramList(Root, "roots"), layout_requests: ProgramList(LayoutRequest, "layout_requests"), runtime_schema_requests: ProgramList(RuntimeSchemaRequest, "runtime_schema_requests"), + static_data_values: ProgramList(StaticDataValue, "static_data_values"), comptime_sites: ProgramList(ComptimeSite, "comptime_sites"), /// Source file table for `SourceLoc.file` indices (copied from the lifted /// program; owned by this program). @@ -490,6 +502,7 @@ pub const Program = struct { .roots = .empty, .layout_requests = .empty, .runtime_schema_requests = .empty, + .static_data_values = .empty, .comptime_sites = .empty, .source_files = .empty, .expr_locs = .empty, @@ -517,6 +530,7 @@ pub const Program = struct { self.allocator.free(site.branch_regions); } self.comptime_sites.deinit(self.allocator); + self.static_data_values.deinit(self.allocator); self.runtime_schema_requests.deinit(self.allocator); self.layout_requests.deinit(self.allocator); self.roots.deinit(self.allocator); diff --git a/src/postcheck/lambda_mono/lower.zig b/src/postcheck/lambda_mono/lower.zig index b5d61903b79..3894a281b43 100644 --- a/src/postcheck/lambda_mono/lower.zig +++ b/src/postcheck/lambda_mono/lower.zig @@ -43,6 +43,7 @@ pub fn run( name_store = undefined; string_literals = undefined; program.source_files = Ast.ProgramList([]const u8, "source_files").fromArrayList(owned.lifted.takeSourceFiles()); + program.static_data_values = Ast.ProgramList(Ast.StaticDataValue, "static_data_values").fromArrayList(owned.lifted.takeStaticDataValues()); errdefer program.deinit(); const solved_view = movedSolvedView(&owned, &program); @@ -88,6 +89,7 @@ fn movedSolvedView(source: *const Solved.Program, moved: *const Ast.Program) Sol .roots = lifted.roots, .layout_requests = lifted.layout_requests, .runtime_schema_requests = lifted.runtime_schema_requests, + .static_data_values = moved.static_data_values.unsafeRawItemsForView(), .comptime_sites = lifted.comptime_sites, .source_files = moved.source_files.unsafeRawItemsForView(), .expr_locs = lifted.expr_locs, @@ -554,6 +556,10 @@ const Lowerer = struct { .dec_lit => |value| .{ .dec_lit = value }, .str_lit => |value| .{ .str_lit = value }, .bytes_lit => |value| .{ .bytes_lit = value }, + .static_data_candidate => |candidate| .{ .static_data_candidate = .{ + .static_data = candidate.static_data, + .runtime_expr = try self.lowerExpr(candidate.runtime_expr), + } }, .uninitialized => .uninitialized, .uninitialized_payload => |payload| .{ .uninitialized_payload = .{ .condition = try self.localFor(payload.condition, try self.lowerType(self.solved.local_tys[@intFromEnum(payload.condition)])), @@ -880,10 +886,18 @@ const Lowerer = struct { capture_ty: Type.TypeId, ) Allocator.Error!Ast.ExprId { const captures = self.solved.types.captureSpan(capture_span); - const fields = switch (self.program.types.get(capture_ty)) { - .capture_record => |fields| self.program.types.captureFieldSpan(fields), - else => Common.invariant("callable capture payload was not a capture record"), + // Own a copy of the capture-record fields: lowering a capture operand + // below can lower a nested callable, which adds capture-record fields and + // may reallocate the store's capture-field backing. A borrowed field span + // would dangle across those calls, so read every field from this snapshot. + const fields = fields: { + const borrowed = switch (self.program.types.get(capture_ty)) { + .capture_record => |fields| self.program.types.captureFieldSpan(fields), + else => Common.invariant("callable capture payload was not a capture record"), + }; + break :fields try GuardedList.dupe(self.allocator, Type.CaptureField, borrowed); }; + defer self.allocator.free(fields); if (captures.len != fields.len) Common.invariant("callable capture payload arity differed from captured locals"); if (captures.len != capture_operands.len) { Common.invariant("function reference capture operand count differed from lifted function captures"); diff --git a/src/postcheck/lambda_mono/type.zig b/src/postcheck/lambda_mono/type.zig index eb86951b5ad..312213a0e3e 100644 --- a/src/postcheck/lambda_mono/type.zig +++ b/src/postcheck/lambda_mono/type.zig @@ -277,6 +277,9 @@ pub const Store = struct { writeBytes(hasher, name_store.moduleIdentityBytes(named.def.module)); writeOptionalU32(hasher, named.def.source_decl); writeBytes(hasher, name_store.typeNameText(named.def.type_name)); + writeOptionalDigest(hasher, named.def.generated); + writeBytes(hasher, @tagName(named.def.iterator_representation)); + writeU32(hasher, named.def.iterator_depth); writeBytes(hasher, @tagName(named.kind)); if (named.builtin_owner) |owner| { writeBytes(hasher, "builtin"); @@ -395,6 +398,15 @@ fn writeOptionalU32(hasher: *std.crypto.hash.sha2.Sha256, value: ?u32) void { } } +fn writeOptionalDigest(hasher: *std.crypto.hash.sha2.Sha256, value: ?names.TypeDigest) void { + if (value) |digest| { + hasher.update(&[_]u8{1}); + hasher.update(&digest.bytes); + } else { + hasher.update(&[_]u8{0}); + } +} + fn writeU32(hasher: *std.crypto.hash.sha2.Sha256, value: u32) void { const little = std.mem.nativeToLittle(u32, value); hasher.update(std.mem.asBytes(&little)); diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index a2e5a0afe63..b6d01fdadbf 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -12,6 +12,7 @@ const Type = @import("type.zig"); const Allocator = std.mem.Allocator; const static_dispatch = check.StaticDispatchRegistry; +const names = check.CheckedNames; const UnifyPair = struct { first: Type.TypeVarId, @@ -432,6 +433,7 @@ const Solver = struct { _ = try self.expectExpr(payload, expected_payload_ty); } }, + .static_data_candidate => |candidate| _ = try self.expectExpr(candidate.runtime_expr, expected), .nominal => |backing| { if (try self.namedBacking(expected)) |backing_ty| { if (self.hasBuiltinOwner(expected, .fields) or self.hasBuiltinOwner(expected, .field)) { @@ -682,6 +684,11 @@ const Solver = struct { _ = try self.inferExpr(payload); } }, + .static_data_candidate => |candidate| { + _ = try self.exprSlot(expr_id); + self.expr_done[index] = true; + try self.inferGeneratedOpaqueBacking(candidate.runtime_expr); + }, .nominal => |backing| { _ = try self.exprSlot(expr_id); self.expr_done[index] = true; @@ -819,9 +826,77 @@ const Solver = struct { const slot = try self.expectExprSlot(expr_id, expected); const inferred = try self.inferExpr(expr_id); try self.unify(slot, inferred); + try self.expectGeneratedIteratorBackingExpr(expr_id, expected, slot, inferred); return self.program.types.root(slot); } + fn expectGeneratedIteratorBackingExpr( + self: *Solver, + expr_id: Lifted.ExprId, + expected: Type.TypeVarId, + slot: Type.TypeVarId, + inferred: Type.TypeVarId, + ) Allocator.Error!void { + const backing_ty = self.generatedIteratorBacking(expected) orelse + self.generatedIteratorBacking(slot) orelse + self.generatedIteratorBacking(inferred) orelse + return; + switch (self.lifted.exprs[@intFromEnum(expr_id)].data) { + .record, + .tuple, + .tag, + .nominal, + .let_, + .static_data_candidate, + => {}, + else => return, + } + try self.expectExprAtTypeEvenIfDone(expr_id, backing_ty); + } + + fn expectExprAtTypeEvenIfDone(self: *Solver, expr_id: Lifted.ExprId, expected: Type.TypeVarId) Allocator.Error!void { + const expr = self.lifted.exprs[@intFromEnum(expr_id)]; + switch (expr.data) { + .record => |fields| { + for (self.lifted.fieldExprSpan(fields)) |field| { + const field_ty = try self.recordField(expected, field.name); + try self.expectExprAtTypeEvenIfDone(field.value, field_ty); + } + }, + .tuple => |items| { + const item_tys = try self.tupleItemsSpan(expected); + const children = self.lifted.exprSpan(items); + if (item_tys.count() != children.len) Common.invariant("tuple expression arity differs from generated backing type"); + for (children, 0..) |child, i| { + try self.expectExprAtTypeEvenIfDone(child, self.program.types.spanItem(item_tys, i)); + } + }, + .tag => |tag| { + const payload_tys = try self.tagPayloadsSpan(expected, tag.name); + const payloads = self.lifted.exprSpan(tag.payloads); + if (payload_tys.count() != payloads.len) Common.invariant("tag expression payload arity differs from generated backing type"); + for (payloads, 0..) |payload, i| { + try self.expectExprAtTypeEvenIfDone(payload, self.program.types.spanItem(payload_tys, i)); + } + }, + .static_data_candidate => |candidate| try self.expectExprAtTypeEvenIfDone(candidate.runtime_expr, expected), + .nominal => |backing| { + const backing_ty = try self.namedBacking(expected) orelse expected; + try self.expectExprAtTypeEvenIfDone(backing, backing_ty); + }, + .let_ => |let_| { + const value_ty = try self.inferExpr(let_.value); + try self.bindPattern(let_.bind, value_ty); + try self.expectExprAtTypeEvenIfDone(let_.rest, expected); + }, + else => { + const slot = try self.expectExprSlot(expr_id, expected); + const inferred = try self.inferExpr(expr_id); + try self.unify(slot, inferred); + }, + } + } + fn exprSlot(self: *Solver, expr_id: Lifted.ExprId) Allocator.Error!Type.TypeVarId { const index = @intFromEnum(expr_id); if (self.expr_tys[index]) |ty| return ty; @@ -918,13 +993,17 @@ const Solver = struct { fn markErasedCallablesReachedByType(self: *Solver, ty: Type.TypeVarId) Allocator.Error!void { var active = std.AutoHashMap(Type.TypeVarId, void).init(self.allocator); defer active.deinit(); - try self.markErasedCallablesReachedByTypeInner(ty, &active); + try self.markErasedCallablesReachedByTypeInner(ty, &active, false); } fn markErasedCallablesReachedByTypeInner( self: *Solver, ty: Type.TypeVarId, active: *std.AutoHashMap(Type.TypeVarId, void), + // True while walking the backing of a bounded `Iter`/`Stream` nominal. + // Its step closure stays a lambda set (inline captures) rather than + // erasing to a boxed callable; every other function still erases. + in_iter_backing: bool, ) Allocator.Error!void { const root = self.program.types.root(ty); if (active.contains(root)) return; @@ -935,47 +1014,56 @@ const Solver = struct { .link => Common.invariant("Lambda Solved root returned a link"), .unbound, .forall, .primitive, .zst, .erased => {}, .func => |func| { - const erased = try self.program.types.add(.{ .erased = .{ - .source_fn_ty = try self.solvedTypeDigest(root), - .members = .empty(), - } }); - try self.unify(func.callable, erased); + if (in_iter_backing) { + try self.markErasedCallablesReachedByTypeInner(func.callable, active, false); + } else { + const erased = try self.program.types.add(.{ .erased = .{ + .source_fn_ty = try self.solvedTypeDigest(root), + .members = .empty(), + } }); + try self.unify(func.callable, erased); + } for (self.program.types.span(func.args)) |arg| { - try self.markErasedCallablesReachedByTypeInner(arg, active); + try self.markErasedCallablesReachedByTypeInner(arg, active, false); } - try self.markErasedCallablesReachedByTypeInner(func.ret, active); + try self.markErasedCallablesReachedByTypeInner(func.ret, active, false); }, - .list => |elem| try self.markErasedCallablesReachedByTypeInner(elem, active), - .box => |elem| try self.markErasedCallablesReachedByTypeInner(elem, active), + .list => |elem| try self.markErasedCallablesReachedByTypeInner(elem, active, in_iter_backing), + .box => |elem| try self.markErasedCallablesReachedByTypeInner(elem, active, in_iter_backing), .tuple => |items| { for (self.program.types.span(items)) |item| { - try self.markErasedCallablesReachedByTypeInner(item, active); + try self.markErasedCallablesReachedByTypeInner(item, active, in_iter_backing); } }, .record => |fields| { for (self.program.types.fieldSpan(fields)) |field| { - try self.markErasedCallablesReachedByTypeInner(field.ty, active); + try self.markErasedCallablesReachedByTypeInner(field.ty, active, in_iter_backing); } }, .tag_union => |tags| { for (self.program.types.tagSpan(tags)) |tag| { for (self.program.types.span(tag.payloads)) |payload| { - try self.markErasedCallablesReachedByTypeInner(payload, active); + try self.markErasedCallablesReachedByTypeInner(payload, active, in_iter_backing); } } }, .named => |named| { for (self.program.types.span(named.args)) |arg| { - try self.markErasedCallablesReachedByTypeInner(arg, active); + try self.markErasedCallablesReachedByTypeInner(arg, active, false); } if (named.backing) |backing| { - try self.markErasedCallablesReachedByTypeInner(backing.ty, active); + const backing_is_iter = named.def.iterator_representation == .minted and + if (named.builtin_owner) |owner| + static_dispatch.isIteratorOwner(owner) + else + false; + try self.markErasedCallablesReachedByTypeInner(backing.ty, active, backing_is_iter); } }, .lambda_set => |members| { for (self.program.types.memberSpan(members)) |member| { for (self.program.types.captureSpan(member.captures)) |capture| { - try self.markErasedCallablesReachedByTypeInner(capture.ty, active); + try self.markErasedCallablesReachedByTypeInner(capture.ty, active, in_iter_backing); } } }, @@ -985,7 +1073,11 @@ const Solver = struct { fn lowerTypeFresh(self: *Solver, ty: MonoType.TypeId) Allocator.Error!Type.TypeVarId { var cloner = TypeCloner.init(self); defer cloner.deinit(); - return try cloner.lower(ty); + const lowered = try cloner.lower(ty); + for (cloner.forced_dynamic_backings.items) |backing| { + try self.markErasedCallablesReachedByType(backing); + } + return lowered; } fn listElem(self: *Solver, ty: Type.TypeVarId) Allocator.Error!Type.TypeVarId { @@ -1047,6 +1139,18 @@ const Solver = struct { }; } + fn generatedIteratorBacking(self: *Solver, ty: Type.TypeVarId) ?Type.TypeVarId { + return switch (self.program.types.rootContent(ty)) { + .named => |named| blk: { + if (named.def.iterator_representation == .none) break :blk null; + const owner = named.builtin_owner orelse break :blk null; + if (!static_dispatch.isIteratorOwner(owner)) break :blk null; + break :blk if (named.backing) |backing| backing.ty else null; + }, + else => null, + }; + } + fn hasBuiltinOwner(self: *Solver, ty: Type.TypeVarId, owner: static_dispatch.BuiltinOwner) bool { return switch (self.program.types.rootContent(ty)) { .named => |named| if (named.builtin_owner) |builtin_owner| builtin_owner == owner else false, @@ -1363,6 +1467,10 @@ const Solver = struct { left_named.kind != right_named.kind or left_named.builtin_owner != right_named.builtin_owner) { + if (try self.unifyForcedDynamicIterator(a, b, left_named, right_named)) return; + if (try self.unifyIteratorOwnerStampedPublic(a, b, left_named, right_named)) return; + if (try self.unifyGeneratedIteratorJoin(a, b, left_named, right_named)) return; + if (try self.unifyPublicGeneratedIterator(a, b, left_named, right_named)) return; Common.invariant("named type identity failed Lambda Solved unification"); } try self.unifySpans(left_named.args, right_named.args, "named type arguments failed Lambda Solved unification"); @@ -1395,6 +1503,123 @@ const Solver = struct { } } + fn unifyIteratorOwnerStampedPublic( + self: *Solver, + left_ty: Type.TypeVarId, + right_ty: Type.TypeVarId, + left: anytype, + right: anytype, + ) Allocator.Error!bool { + if (left.kind != right.kind) return false; + if (!sameMonoTypeDef(left.def, right.def)) return false; + _ = iteratorLikeOwnerFromPair(left.builtin_owner, right.builtin_owner) orelse return false; + if (left.builtin_owner == right.builtin_owner) return false; + + try self.unifySpans(left.args, right.args, "iterator owner-stamp argument lists failed Lambda Solved unification"); + if (isIteratorLikeOwner(left.builtin_owner)) { + self.program.types.set(right_ty, .{ .link = left_ty }); + } else { + self.program.types.set(left_ty, .{ .link = right_ty }); + } + return true; + } + + fn unifyForcedDynamicIterator( + self: *Solver, + left_ty: Type.TypeVarId, + right_ty: Type.TypeVarId, + left: anytype, + right: anytype, + ) Allocator.Error!bool { + if (left.kind != right.kind) return false; + if (!sameSourceTypeDef(left.def, right.def)) return false; + _ = iteratorLikeOwnerFromPair(left.builtin_owner, right.builtin_owner) orelse return false; + + const left_dynamic = left.def.iterator_representation == .forced_dynamic; + const right_dynamic = right.def.iterator_representation == .forced_dynamic; + if (left_dynamic == right_dynamic) return false; + if (left.args.count() == 0 or right.args.count() == 0) { + Common.invariant("forced-dynamic iterator reached Lambda Solved without a public item argument"); + } + + try self.unify(self.program.types.spanItem(left.args, 0), self.program.types.spanItem(right.args, 0)); + try self.unifyIteratorBackings(left, right); + if (left_dynamic) { + self.program.types.set(right_ty, .{ .link = left_ty }); + } else { + self.program.types.set(left_ty, .{ .link = right_ty }); + } + return true; + } + + fn unifyGeneratedIteratorJoin( + self: *Solver, + left_ty: Type.TypeVarId, + right_ty: Type.TypeVarId, + left: anytype, + right: anytype, + ) Allocator.Error!bool { + if (!isGeneratedIteratorJoinPair(left, right)) return false; + + if (left.args.count() == 0 or right.args.count() == 0) { + Common.invariant("generated iterator join reached Lambda Solved without a public item argument"); + } + try self.unify(self.program.types.spanItem(left.args, 0), self.program.types.spanItem(right.args, 0)); + + if (left.backing) |left_backing| { + const right_backing = right.backing orelse + Common.invariant("generated iterator join found backing on only one side"); + if (left_backing.use != right_backing.use) { + Common.invariant("generated iterator join found different backing uses"); + } + try self.unify(left_backing.ty, right_backing.ty); + } else if (right.backing != null) { + Common.invariant("generated iterator join found backing on only one side"); + } + + if (isIteratorLikeOwner(left.builtin_owner)) { + self.program.types.set(right_ty, .{ .link = left_ty }); + } else { + self.program.types.set(left_ty, .{ .link = right_ty }); + } + return true; + } + + fn unifyPublicGeneratedIterator( + self: *Solver, + left_ty: Type.TypeVarId, + right_ty: Type.TypeVarId, + left: anytype, + right: anytype, + ) Allocator.Error!bool { + if (!isPublicGeneratedIteratorPair(left, right)) return false; + + if (left.args.count() == 0 or right.args.count() == 0) { + Common.invariant("generated iterator evidence reached Lambda Solved without a public item argument"); + } + try self.unify(self.program.types.spanItem(left.args, 0), self.program.types.spanItem(right.args, 0)); + + if (left.def.iterator_representation == .minted) { + self.program.types.set(right_ty, .{ .link = left_ty }); + } else { + self.program.types.set(left_ty, .{ .link = right_ty }); + } + return true; + } + + fn unifyIteratorBackings(self: *Solver, left: anytype, right: anytype) Allocator.Error!void { + if (left.backing) |left_backing| { + const right_backing = right.backing orelse + Common.invariant("iterator unification found backing on only one side"); + if (left_backing.use != right_backing.use) { + Common.invariant("iterator unification found different backing uses"); + } + try self.unify(left_backing.ty, right_backing.ty); + } else if (right.backing != null) { + Common.invariant("iterator unification found backing on only one side"); + } + } + fn transparentAliasBacking(content: Type.Content) ?Type.TypeVarId { return switch (content) { .named => |named| if (named.kind == .alias) @@ -1614,15 +1839,18 @@ fn writeU32(hasher: *std.crypto.hash.sha2.Sha256, value: u32) void { const TypeCloner = struct { solver: *Solver, map: std.AutoHashMap(MonoType.TypeId, Type.TypeVarId), + forced_dynamic_backings: std.ArrayList(Type.TypeVarId), fn init(solver: *Solver) TypeCloner { return .{ .solver = solver, .map = std.AutoHashMap(MonoType.TypeId, Type.TypeVarId).init(solver.allocator), + .forced_dynamic_backings = .empty, }; } fn deinit(self: *TypeCloner) void { + self.forced_dynamic_backings.deinit(self.solver.allocator); self.map.deinit(); } @@ -1630,7 +1858,13 @@ const TypeCloner = struct { if (self.map.get(ty)) |cached| return cached; const reserved = try self.solver.program.types.add(.unbound); try self.map.put(ty, reserved); - self.solver.program.types.set(reserved, try self.lowerContent(self.solver.lifted.types.get(ty))); + const content = try self.lowerContent(self.solver.lifted.types.get(ty)); + self.solver.program.types.set(reserved, content); + if (content == .named and content.named.def.iterator_representation == .forced_dynamic) { + const backing = content.named.backing orelse + Common.invariant("forced-dynamic iterator reached Lambda Solved without a backing type"); + try self.forced_dynamic_backings.append(self.solver.allocator, backing.ty); + } return reserved; } @@ -1771,12 +2005,72 @@ fn isGeneratedOpaqueEvidenceOwner(owner: ?static_dispatch.BuiltinOwner) bool { }; } -fn sameMonoTypeDef(left: MonoType.TypeDef, right: MonoType.TypeDef) bool { +fn isPublicGeneratedIteratorPair(left: anytype, right: anytype) bool { + if (left.kind != right.kind) return false; + _ = iteratorLikeOwnerFromPair(left.builtin_owner, right.builtin_owner) orelse return false; + if (!sameSourceTypeDef(left.def, right.def)) return false; + return (left.def.iterator_representation == .minted and right.def.iterator_representation == .none) or + (left.def.iterator_representation == .none and right.def.iterator_representation == .minted); +} + +fn isGeneratedIteratorJoinPair(left: anytype, right: anytype) bool { + if (left.kind != right.kind) return false; + _ = iteratorLikeOwnerFromPair(left.builtin_owner, right.builtin_owner) orelse return false; + if (!sameSourceTypeDef(left.def, right.def)) return false; + return left.def.iterator_representation == .minted and + right.def.iterator_representation == .minted and + !optionalDigestEql(left.def.generated, right.def.generated); +} + +fn iteratorLikeOwnerFromPair( + left: ?static_dispatch.BuiltinOwner, + right: ?static_dispatch.BuiltinOwner, +) ?static_dispatch.BuiltinOwner { + if (left) |left_owner| { + if (!isIteratorLikeOwner(left_owner)) return null; + if (right) |right_owner| { + if (left_owner != right_owner) return null; + } + return left_owner; + } + if (right) |right_owner| { + if (!isIteratorLikeOwner(right_owner)) return null; + return right_owner; + } + return null; +} + +fn isIteratorLikeOwner(owner: ?static_dispatch.BuiltinOwner) bool { + const actual = owner orelse return false; + return switch (actual) { + .iter, + .stream, + => true, + else => false, + }; +} + +fn sameSourceTypeDef(left: MonoType.TypeDef, right: MonoType.TypeDef) bool { return left.module == right.module and left.type_name == right.type_name and left.source_decl == right.source_decl; } +fn sameMonoTypeDef(left: MonoType.TypeDef, right: MonoType.TypeDef) bool { + return left.module == right.module and + left.type_name == right.type_name and + left.source_decl == right.source_decl and + optionalDigestEql(left.generated, right.generated) and + left.iterator_representation == right.iterator_representation and + left.iterator_depth == right.iterator_depth; +} + +fn optionalDigestEql(left: ?names.TypeDigest, right: ?names.TypeDigest) bool { + if (left == null and right == null) return true; + if (left == null or right == null) return false; + return std.mem.eql(u8, left.?.bytes[0..], right.?.bytes[0..]); +} + test "lambda solved solve declarations are referenced" { std.testing.refAllDecls(@This()); } diff --git a/src/postcheck/lir_lower.zig b/src/postcheck/lir_lower.zig index 28790189f5b..1adb212a16c 100644 --- a/src/postcheck/lir_lower.zig +++ b/src/postcheck/lir_lower.zig @@ -134,6 +134,7 @@ const Lowerer = struct { type_layouts: []?layout.Idx, const_plan_map: []?LirProgram.ConstPlanId, const_type_map: []?check.ConstStore.ConstTypeId, + static_data_map: []?LIR.StaticDataId, next_join_point: u32 = 0, loop_stack: std.ArrayList(LoopContext), current_ret_ty: ?Type.TypeId = null, @@ -177,6 +178,10 @@ const Lowerer = struct { errdefer allocator.free(const_type_map); @memset(const_type_map, null); + const static_data_map = try allocator.alloc(?LIR.StaticDataId, program.static_data_values.len()); + errdefer allocator.free(static_data_map); + @memset(static_data_map, null); + return .{ .allocator = allocator, .program = program, @@ -188,12 +193,14 @@ const Lowerer = struct { .type_layouts = type_layouts, .const_plan_map = const_plan_map, .const_type_map = const_type_map, + .static_data_map = static_data_map, .loop_stack = .empty, }; } fn deinit(self: *Lowerer) void { self.loop_stack.deinit(self.allocator); + self.allocator.free(self.static_data_map); self.allocator.free(self.const_type_map); self.allocator.free(self.const_plan_map); self.allocator.free(self.type_layouts); @@ -210,6 +217,7 @@ const Lowerer = struct { .runtime_schemas = self.runtime_schemas, }; self.loop_stack.deinit(self.allocator); + self.allocator.free(self.static_data_map); self.allocator.free(self.const_type_map); self.allocator.free(self.const_plan_map); self.allocator.free(self.type_layouts); @@ -222,6 +230,7 @@ const Lowerer = struct { self.type_layouts = &.{}; self.const_plan_map = &.{}; self.const_type_map = &.{}; + self.static_data_map = &.{}; self.loop_stack = .empty; return output; } @@ -419,6 +428,102 @@ const Lowerer = struct { return id; } + fn lirStaticDataFor( + self: *Lowerer, + id: Common.StaticDataId, + layout_idx: layout.Idx, + plan: LirProgram.ConstPlanId, + ) Common.LowerError!LIR.StaticDataId { + const raw = @intFromEnum(id); + if (raw >= self.static_data_map.len) Common.invariant("static data candidate id exceeded Lambda Mono static data table"); + if (self.static_data_map[raw]) |existing| return existing; + + const source = GuardedList.at(self.program.static_data_values.unsafeRawItemsForView(), raw); + const result_id: LIR.StaticDataId = @enumFromInt(@as(u32, @intCast(self.result.static_data_values.items.len))); + try self.result.static_data_values.append(self.allocator, .{ + .const_locator = source.const_locator, + .node = source.node, + .checked_type = source.checked_type, + .layout_idx = layout_idx, + .plan = plan, + }); + self.static_data_map[raw] = result_id; + return result_id; + } + + fn constPlanNeedsStaticData(self: *Lowerer, plan_id: LirProgram.ConstPlanId, layout_idx: layout.Idx) bool { + const layout_data = self.result.layouts.getLayout(layout_idx); + if (self.result.layouts.layoutSize(layout_data) == 0) return false; + return switch (self.result.const_plans.items[@intFromEnum(plan_id)]) { + .pending => Common.invariant("pending const plan reached static-data selection"), + .zst, + .scalar, + => false, + .str, + .list, + .box, + .fn_value, + .erased_fn, + => true, + .tuple => |plans| self.aggregatePlanNeedsStaticData(plans, layout_idx), + .record => |plans| self.aggregatePlanNeedsStaticData(plans, layout_idx), + .tag_union => |variants| self.tagPlanNeedsStaticData(variants, layout_idx), + .named => |named| switch (layout_data.tag) { + .box, + .box_of_zst, + => true, + else => self.constPlanNeedsStaticData(named.backing, layout_idx), + }, + }; + } + + fn aggregatePlanNeedsStaticData(self: *Lowerer, plans: []const LirProgram.ConstPlanId, layout_idx: layout.Idx) bool { + if (plans.len == 0) return false; + const layout_data = self.result.layouts.getLayout(layout_idx); + return switch (layout_data.tag) { + .zst => false, + .box, + .box_of_zst, + .list, + .list_of_zst, + .closure, + .erased_callable, + => true, + .struct_ => blk: { + const struct_idx = layout_data.getStruct().idx; + for (plans, 0..) |plan, index| { + const field_layout = self.result.layouts.getStructFieldLayoutByOriginalIndex(struct_idx, @intCast(index)); + if (self.constPlanNeedsStaticData(plan, field_layout)) break :blk true; + } + break :blk false; + }, + else => Common.invariant("aggregate const plan reached a non-aggregate layout"), + }; + } + + fn tagPlanNeedsStaticData(self: *Lowerer, variants: []const LirProgram.ConstTagVariant, layout_idx: layout.Idx) bool { + const layout_data = self.result.layouts.getLayout(layout_idx); + switch (layout_data.tag) { + .zst, .scalar => return false, + .box, .box_of_zst => return true, + .tag_union => {}, + else => Common.invariant("tag const plan reached a non-tag layout"), + } + const data = self.result.layouts.getTagUnionData(layout_data.getTagUnion().idx); + const layout_variants = self.result.layouts.getTagUnionVariants(data); + if (layout_variants.len != variants.len) Common.invariant("tag const plan variant count differed from layout variant count"); + for (variants, 0..) |variant, index| { + if (variant.payloads.len == 0) continue; + const payload_layout = layout_variants.get(@intCast(index)).payload_layout; + if (variant.payloads.len == 1) { + if (self.constPlanNeedsStaticData(variant.payloads[0], payload_layout)) return true; + } else if (self.aggregatePlanNeedsStaticData(variant.payloads, payload_layout)) { + return true; + } + } + return false; + } + fn buildConstPlan(self: *Lowerer, ty: Type.TypeId) Common.LowerError!LirProgram.ConstPlan { return switch (self.program.types.get(ty)) { .primitive => |primitive| switch (primitive) { @@ -538,6 +643,9 @@ const Lowerer = struct { .module = try self.result.const_type_names.internModuleIdentity(self.program.names.moduleIdentityBytes(def.module)), .type_name = try self.result.const_type_names.internTypeName(self.program.names.typeNameText(def.type_name)), .source_decl = def.source_decl, + .generated = def.generated, + .iterator_representation = @enumFromInt(@intFromEnum(def.iterator_representation)), + .iterator_depth = def.iterator_depth, }; } @@ -869,6 +977,25 @@ const Lowerer = struct { return try self.lowerExprInto(ret_local, expr_id, ret_stmt); } + fn lowerStaticDataCandidateInto( + self: *Lowerer, + target: LIR.LocalId, + candidate: LambdaMono.StaticDataCandidate, + ty: Type.TypeId, + next: LIR.CFStmtId, + ) Common.LowerError!LIR.CFStmtId { + const layout_idx = self.result.store.getLocal(target).layout_idx; + const plan = try self.constPlanOfType(ty); + if (self.constPlanNeedsStaticData(plan, layout_idx)) { + return try self.result.store.addCFStmt(.{ .assign_literal = .{ + .target = target, + .value = .{ .static_data = try self.lirStaticDataFor(candidate.static_data, layout_idx, plan) }, + .next = next, + } }); + } + return try self.lowerExprInto(target, candidate.runtime_expr, next); + } + fn lowerExprInto( self: *Lowerer, target: LIR.LocalId, @@ -931,6 +1058,7 @@ const Lowerer = struct { } }); }, .uninitialized, .uninitialized_payload => next, + .static_data_candidate => |candidate| try self.lowerStaticDataCandidateInto(target, candidate, expr_data.ty, next), .list => |items| try self.lowerListInto(target, items, next), .tuple => |items| try self.lowerTupleInto(target, items, next), .record => |fields| try self.lowerRecordInto(target, expr_data.ty, fields, next), @@ -3821,6 +3949,8 @@ const Lowerer = struct { .crypto_sha256_hasher, .crypto_blake3_digest, .crypto_blake3_hasher, + .iter, + .stream, => null, }; } diff --git a/src/postcheck/monotype/ast.zig b/src/postcheck/monotype/ast.zig index 4bf69d6402f..9017ddf8549 100644 --- a/src/postcheck/monotype/ast.zig +++ b/src/postcheck/monotype/ast.zig @@ -503,6 +503,13 @@ pub const Expr = struct { data: ExprData, }; +/// A restored compile-time value that may lower to static data once the final +/// LIR const plan and target layout are known. +pub const StaticDataCandidate = struct { + static_data: Common.StaticDataId, + runtime_expr: ExprId, +}; + /// A checked early return plus the explicit target lambda return type. pub const Return = struct { value: ExprId, @@ -519,6 +526,7 @@ pub const ExprData = union(enum(u8)) { dec_lit: builtins.dec.RocDec, str_lit: StringLiteralId, bytes_lit: StringLiteralId, + static_data_candidate: StaticDataCandidate, list: Span(ExprId), tuple: Span(ExprId), record: Span(FieldExpr), @@ -795,6 +803,9 @@ pub const RuntimeSchemaRequest = struct { ty: Type.TypeId, }; +/// Request to make a Monotype value available as static data. +pub const StaticDataValue = Common.StaticDataRequest; + /// Errors reported by Monotype program-view call-target verification. pub const CallTargetVerifyError = enum { local_fn_out_of_bounds, @@ -854,6 +865,7 @@ pub const ProgramView = struct { roots: []const Root, layout_requests: []const LayoutRequest, runtime_schema_requests: []const RuntimeSchemaRequest, + static_data_values: []const StaticDataValue, comptime_sites: []const ComptimeSite, source_files: []const []const u8, expr_locs: []const base.SourceLoc, @@ -1016,6 +1028,7 @@ pub const ProgramBuilder = struct { roots: ProgramList(Root, "roots"), layout_requests: ProgramList(LayoutRequest, "layout_requests"), runtime_schema_requests: ProgramList(RuntimeSchemaRequest, "runtime_schema_requests"), + static_data_values: ProgramList(StaticDataValue, "static_data_values"), comptime_sites: ProgramList(ComptimeSite, "comptime_sites"), /// Source file table for `SourceLoc.file` indices (module display names, /// owned by this program). @@ -1069,6 +1082,7 @@ pub const ProgramBuilder = struct { .roots = .empty, .layout_requests = .empty, .runtime_schema_requests = .empty, + .static_data_values = .empty, .comptime_sites = .empty, .source_files = .empty, .expr_locs = .empty, @@ -1096,6 +1110,7 @@ pub const ProgramBuilder = struct { self.allocator.free(site.branch_regions); } self.comptime_sites.deinit(self.allocator); + self.static_data_values.deinit(self.allocator); self.runtime_schema_requests.deinit(self.allocator); self.layout_requests.deinit(self.allocator); self.roots.deinit(self.allocator); @@ -1265,6 +1280,7 @@ pub const ProgramBuilder = struct { .roots = self.roots.unsafeRawItemsForView(), .layout_requests = self.layout_requests.unsafeRawItemsForView(), .runtime_schema_requests = self.runtime_schema_requests.unsafeRawItemsForView(), + .static_data_values = self.static_data_values.unsafeRawItemsForView(), .comptime_sites = self.comptime_sites.unsafeRawItemsForView(), .source_files = self.source_files.unsafeRawItemsForView(), .expr_locs = self.expr_locs.unsafeRawItemsForView(), @@ -1542,6 +1558,12 @@ pub const ProgramBuilder = struct { try self.runtime_schema_requests.append(self.allocator, request); } + pub fn addStaticDataValue(self: *ProgramBuilder, value: StaticDataValue) std.mem.Allocator.Error!Common.StaticDataId { + const id: Common.StaticDataId = @enumFromInt(@as(u32, @intCast(self.static_data_values.len()))); + try self.static_data_values.append(self.allocator, value); + return id; + } + pub fn comptimeSiteCount(self: *const ProgramBuilder) usize { return self.comptime_sites.len(); } diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 6750e8a6dca..ed377d977a4 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -72,6 +72,10 @@ pub const Options = struct { /// Whether inline expects should be lowered at all. Optimized runtime builds /// omit them before their conditions can affect control-flow decisions. inline_expects: InlineExpectMode = .run, + /// Restore stored constants as readonly static-data candidates when their + /// ConstStore shape may require runtime storage. + static_data_literals: bool = false, + target_usize: base.target.TargetUsize = base.target.TargetUsize.native, }; /// Deterministic counters used by specialization-shape tests. @@ -440,6 +444,26 @@ const ParseIntrinsic = enum { field_name, }; +/// Internal iterator-family nominal kind. +/// +/// These are not source-visible nominal declarations like `MapIter`. The +/// distinct compiler-owned nominal identity is the `TypeDef.generated` digest +/// minted from this kind plus the concrete component types. That digest is the +/// identity; display names stay the public `Iter` provenance. +const GeneratedIteratorKind = enum { + custom, + single, + range_exclusive, + range_inclusive, + map, + keep_if, + drop_if, + take_first, + drop_first, + concat, + append, +}; + const FieldNameBound = enum { shortest, longest, @@ -457,6 +481,12 @@ const ConstExprAddress = struct { mono_ty: u32, }; +const StaticDataUse = struct { + module: checked.ModuleId, + node: checked.ConstNodeId, + checked_type: checked.CheckedTypeId, +}; + /// Key for a memoized structural-derivation helper def. `value_ty` is the type /// being derived over; `result_ty` is the derivation's auxiliary type (the /// produced Str for inspect, the Bool for equality, the Hasher for hashing). @@ -546,8 +576,12 @@ const Builder = struct { loaded_specialization_shards: []const LoadedSpecializationShard, counters: ?*SpecializationCounters, inline_expects: InlineExpectMode, + static_data_literals: bool, + target_usize: base.target.TargetUsize, symbols: Common.SymbolGen = .{}, type_cache: std.AutoHashMap(CheckedTypeAddress, Type.TypeId), + generated_iter_types: std.AutoHashMap([32]u8, Type.TypeId), + forced_dynamic_iter_types: std.AutoHashMap([32]u8, Type.TypeId), spec_store: specialize.SpecBuilder, /// Monotypes owned by the builder-global type cache. They are lowered /// without body evidence, so empty tag unions inside them are unresolved @@ -559,6 +593,7 @@ const Builder = struct { lowered_nested_by_fn: std.AutoHashMap(Ast.FnId, Ast.SpecId), nested_site_cache: std.AutoHashMap(NestedSiteAddress, names.ProcSiteId), const_expr_cache: std.AutoHashMap(ConstExprAddress, Ast.ExprId), + static_data_ids: std.AutoHashMap(StaticDataUse, Common.StaticDataId), inspect_defs: std.AutoHashMap(GeneratedHelperDefAddress, GeneratedHelperDefEntry), equality_defs: std.AutoHashMap(GeneratedHelperDefAddress, GeneratedHelperDefEntry), hash_defs: std.AutoHashMap(GeneratedHelperDefAddress, GeneratedHelperDefEntry), @@ -600,13 +635,18 @@ const Builder = struct { .loaded_specialization_shards = options.loaded_specialization_shards, .counters = options.specialization_counters, .inline_expects = options.inline_expects, + .static_data_literals = options.static_data_literals, + .target_usize = options.target_usize, .type_cache = std.AutoHashMap(CheckedTypeAddress, Type.TypeId).init(allocator), + .generated_iter_types = std.AutoHashMap([32]u8, Type.TypeId).init(allocator), + .forced_dynamic_iter_types = std.AutoHashMap([32]u8, Type.TypeId).init(allocator), .spec_store = spec_store, .unsolved_monos = std.AutoHashMap(Type.TypeId, void).init(allocator), .lowered_templates = std.AutoHashMap(Ast.FnId, LoweredTemplate).init(allocator), .lowered_nested_by_fn = std.AutoHashMap(Ast.FnId, Ast.SpecId).init(allocator), .nested_site_cache = std.AutoHashMap(NestedSiteAddress, names.ProcSiteId).init(allocator), .const_expr_cache = std.AutoHashMap(ConstExprAddress, Ast.ExprId).init(allocator), + .static_data_ids = std.AutoHashMap(StaticDataUse, Common.StaticDataId).init(allocator), .inspect_defs = std.AutoHashMap(GeneratedHelperDefAddress, GeneratedHelperDefEntry).init(allocator), .equality_defs = std.AutoHashMap(GeneratedHelperDefAddress, GeneratedHelperDefEntry).init(allocator), .hash_defs = std.AutoHashMap(GeneratedHelperDefAddress, GeneratedHelperDefEntry).init(allocator), @@ -642,12 +682,15 @@ const Builder = struct { self.hash_defs.deinit(); self.equality_defs.deinit(); self.inspect_defs.deinit(); + self.static_data_ids.deinit(); self.const_expr_cache.deinit(); self.nested_site_cache.deinit(); self.lowered_nested_by_fn.deinit(); self.lowered_templates.deinit(); self.spec_store.deinit(); self.unsolved_monos.deinit(); + self.generated_iter_types.deinit(); + self.forced_dynamic_iter_types.deinit(); self.type_cache.deinit(); self.evidence_arena.deinit(); } @@ -887,6 +930,24 @@ const Builder = struct { }; } + /// The builtin `Iter`/`Stream` nominals carry no `CheckedBuiltinNominal` + /// tag, so recognize them by their declaring module and name to stamp a + /// `BuiltinOwner`. Downstream layout/erasure stages read that owner to keep + /// the step closure inline instead of erasing it to a boxed callable. + fn builtinOwnerForNominal( + self: *Builder, + view: ModuleView, + nominal: checked.CheckedNominalType, + ) ?static_dispatch.BuiltinOwner { + if (builtinOwner(nominal.builtin)) |owner| return owner; + const source_view = self.moduleForDigest(self.moduleDigestForOrigin(view, nominal.origin_module)); + if (source_view.module_env.module_role != .builtin) return null; + const name_text = view.names.typeNameText(nominal.name); + if (Ident.textEql(name_text, "Iter")) return .iter; + if (Ident.textEql(name_text, "Stream")) return .stream; + return null; + } + fn declaredModuleForAlias(self: *Builder, view: ModuleView, alias: checked.CheckedAliasType) names.CheckedModuleDigest { return self.moduleDigestForOrigin(view, alias.origin_module); } @@ -928,14 +989,16 @@ const Builder = struct { } fn lowerRoot(self: *Builder, request: checked.RootRequest) Allocator.Error!void { - const def = if (request.procedure_template) |template| - try self.lowerTemplate(template, moduleView(self.root_view), request.checked_type) - else if (request.procedure_binding) |binding| - try self.lowerProcedureBindingRoot(request, binding) - else if (request.procedure_use) |procedure| - try self.lowerProcedureUseRoot(request, procedure) - else - Common.invariant("root request reached Monotype without a checked procedure template or procedure source"); + const def = if (request.procedure_template) |template| blk: { + const def = try self.lowerTemplate(template, moduleView(self.root_view), request.checked_type); + break :blk def; + } else if (request.procedure_binding) |binding| blk: { + const def = try self.lowerProcedureBindingRoot(request, binding); + break :blk def; + } else if (request.procedure_use) |procedure| blk: { + const def = try self.lowerProcedureUseRoot(request, procedure); + break :blk def; + } else Common.invariant("root request reached Monotype without a checked procedure template or procedure source"); try self.appendRuntimeSchemaRequestsForDef(def); try self.program.addRoot(.{ .def = def, .request = request }); } @@ -951,9 +1014,9 @@ const Builder = struct { fn lowerStaticDataRequest(self: *Builder, request: Common.StaticDataRequest) Allocator.Error!void { const type_view = moduleView(self.root_view); - const ret_ty = try self.lowerType(type_view, request.data.checked_type); - const const_node = self.providedConstNode(request.data); - const body = try self.restoreConstNodeAtType(const_node.module, type_view, const_node.id, ret_ty); + const ret_ty = try self.lowerType(type_view, request.checked_type); + const const_node = self.constNode(request.const_locator, request.node); + const body = try self.restoreConstNodeAtTypeWithStaticRoot(const_node.module, type_view, const_node.id, ret_ty, request.const_locator); const def = try self.program.addDef(.{ .symbol = self.symbols.fresh(), .fn_def = null, @@ -962,7 +1025,7 @@ const Builder = struct { .ret = ret_ty, }); try self.program.addLayoutRequest(.{ - .checked_type = request.data.checked_type, + .checked_type = request.checked_type, .ty = ret_ty, .def = def, }); @@ -1189,7 +1252,7 @@ const Builder = struct { const template = view.callable_eval_templates.templates[raw]; const root = view.compile_time_roots.root(template.root); return switch (root.payload) { - .fn_value => |fn_id| try self.restoreConstFnExpr(view, view, fn_id, mono_fn_ty), + .fn_value => |fn_id| try self.restoreConstFnExpr(view, view, fn_id, mono_fn_ty, null), .pending => try self.lowerPendingCallableEvalBindingValue(view, template, root, mono_fn_ty), else => Common.invariant("callable eval binding root did not output a callable value"), }; @@ -1295,7 +1358,9 @@ const Builder = struct { switch (record.status) { .ready, .lowering, - => return existing.def, + => { + return existing.def; + }, .reserved => { reserved_def = existing.def; lower_fn_ty = record.request_fn_ty; @@ -1318,7 +1383,9 @@ const Builder = struct { switch (hit.status) { .ready, .lowering, - => return existing.def, + => { + return existing.def; + }, .reserved => { reserved_def = existing.def; // A reserved record can only have matched through its @@ -1485,7 +1552,7 @@ const Builder = struct { // serve many specializations. const root_node = try body_ctx.instNode(template.checked_fn_root); const body_uses_generated_evidence = - !self.unsolved_monos.contains(lower_fn_ty) and body_ctx.functionHasGeneratedOpaqueEvidence(lower_fn_ty); + !self.unsolved_monos.contains(lower_fn_ty) and try body_ctx.functionHasGeneratedOpaqueEvidence(lower_fn_ty); if (!self.unsolved_monos.contains(lower_fn_ty) and !body_uses_generated_evidence) { try graph.addMonoView(root_node, lower_fn_ty); } @@ -1874,7 +1941,7 @@ const Builder = struct { .named_type = .{ .module = self.declaredModuleForNominal(view, nominal), .ty = checked_ty }, .def = try self.typeDef(view, nominal.origin_module, nominal.name, nominal.source_decl), .kind = if (nominal.is_opaque) .@"opaque" else .nominal, - .builtin_owner = builtinOwner(nominal.builtin), + .builtin_owner = self.builtinOwnerForNominal(view, nominal), .args = try self.program.types.addSpan(args), .backing = switch (nominal.representation) { .opaque_without_backing => null, @@ -2035,6 +2102,11 @@ const Builder = struct { .zst, => false, .named => |named| blk: { + if (named.def.iterator_representation != .none) { + if (named.builtin_owner) |owner| { + if (owner == .iter or owner == .stream) break :blk true; + } + } if (self.generatedFieldNamesBackingInfo(ty) or self.generatedParseTagUnionSpecBackingInfo(ty)) { @@ -2513,9 +2585,10 @@ const Builder = struct { return try self.program.types.addDeclaredFields(entries); } - fn providedConstNode(self: *Builder, data: checked.ProvidedDataExport) ConstNode { - const view = self.moduleForId(checked.constModuleId(data.const_ref)); - const template = view.const_templates.get(data.const_ref); + fn constNode(self: *Builder, const_locator: checked.ConstLocator, node: ?checked.ConstNodeId) ConstNode { + const view = self.moduleForId(checked.constModuleId(const_locator)); + if (node) |id| return .{ .module = view, .id = id }; + const template = view.const_templates.get(const_locator); return switch (template.state) { .stored_const => |stored| .{ .module = view, .id = stored.node }, .reserved, @@ -2524,6 +2597,63 @@ const Builder = struct { }; } + fn staticDataValue( + self: *Builder, + const_locator: checked.ConstLocator, + node: ?checked.ConstNodeId, + checked_type: checked.CheckedTypeId, + ) Allocator.Error!Common.StaticDataId { + const const_node = self.constNode(const_locator, node); + const gop = try self.static_data_ids.getOrPut(.{ + .module = const_node.module.key, + .node = const_node.id, + .checked_type = checked_type, + }); + if (!gop.found_existing) { + gop.value_ptr.* = try self.program.addStaticDataValue(.{ + .const_locator = const_locator, + .node = node, + .checked_type = checked_type, + }); + } + return gop.value_ptr.*; + } + + const BareFnCandidate = enum { + disallow, + allow, + }; + + fn constNodeMayUseStaticDataCandidate(self: *Builder, view: ModuleView, node: checked.ConstNodeId, bare_fn: BareFnCandidate) bool { + return self.constValueMayUseStaticDataCandidate(view, view.const_store.get(node), bare_fn); + } + + fn constValueMayUseStaticDataCandidate(self: *Builder, view: ModuleView, value: checked.ConstValue, bare_fn: BareFnCandidate) bool { + return switch (value) { + .pending => Common.invariant("pending ConstStore node reached static data selection"), + .zst, + .scalar, + .crash, + => false, + .str => |str| self.constStrNeedsStaticData(view, str), + .fn_value => bare_fn == .allow, + .list => |items| items.len != 0, + .box => true, + .tuple, + .record, + .tag, + => true, + .nominal => |nominal| self.constValueMayUseStaticDataCandidate(view, view.const_store.get(nominal.backing), .allow), + }; + } + + fn constStrNeedsStaticData(self: *Builder, view: ModuleView, str: check.ConstStore.ConstStr) bool { + const str_bytes = view.const_store.strBytes(str); + const backing = view.const_store.strData(str.data); + const roc_str_size = self.target_usize.size() * 3; + return backing.len >= roc_str_size or str_bytes.len >= roc_str_size; + } + fn lookupMethodTarget( self: *Builder, owner: static_dispatch.MethodOwner, @@ -2785,7 +2915,8 @@ const Builder = struct { ) Allocator.Error!Ast.FnId { const nested_ctx = try self.allocator.create(BodyContext); errdefer self.allocator.destroy(nested_ctx); - nested_ctx.* = try source_ctx.nestedInstantiationContext(Ast.fnTemplateDigest(fn_template, &self.program.types, &self.program.names)); + const nested_key = Ast.fnTemplateDigest(fn_template, &self.program.types, &self.program.names); + nested_ctx.* = try source_ctx.nestedInstantiationContext(nested_key); if (nested_evidence) |vector| { // A generalized local function gets its own vector at depth 0; the // enclosing chain stays reachable at higher depths (compile-time @@ -2839,7 +2970,7 @@ const Builder = struct { const root_node = try request.ctx.instNode(fn_template.source_fn_ty); const body_uses_generated_evidence = - !self.unsolved_monos.contains(fn_template.mono_fn_ty) and request.ctx.functionHasGeneratedOpaqueEvidence(fn_template.mono_fn_ty); + !self.unsolved_monos.contains(fn_template.mono_fn_ty) and try request.ctx.functionHasGeneratedOpaqueEvidence(fn_template.mono_fn_ty); if (!body_uses_generated_evidence) { if (request.ctx.graph.monoViewNode(fn_template.mono_fn_ty)) |request_node| { try request.ctx.graph.unify(root_node, request_node); @@ -3105,6 +3236,7 @@ const Builder = struct { store_view: ModuleView, fn_view: ModuleView, fn_value: check.ConstStore.ConstFn, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!std.ArrayList(RestoredConstSourceCapture) { var capture_count: usize = 0; for (fn_value.captures) |capture| { @@ -3137,7 +3269,10 @@ const Builder = struct { var out_index: usize = 0; for (fn_value.captures) |capture| { if (!capture.id.isCanonical()) continue; - out.items[out_index].value = try fn_ctx.restoreConstNodeAtType(store_view, fn_view, capture.value, out.items[out_index].ty); + out.items[out_index].value = if (static_data_const_locator) |const_locator| + try fn_ctx.restoredStaticDataCandidateNode(store_view, fn_view, capture.value, out.items[out_index].ty, const_locator, checkedBinderType(fn_view, capture.id.binder()), .allow) + else + try fn_ctx.restoreConstNodeAtType(store_view, fn_view, capture.value, out.items[out_index].ty); out_index += 1; } @@ -3291,6 +3426,7 @@ const Builder = struct { } return false; }, + .static_data_candidate => |candidate| return try self.exprDependsOnFreeLocalInner(candidate.runtime_expr, target, bound, active_fns), .nominal, .dbg, .expect, @@ -3625,15 +3761,16 @@ const Builder = struct { type_view: ModuleView, fn_id: checked.ConstFnId, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.ExprId { const raw = @intFromEnum(fn_id); if (raw >= store_view.const_store.fns.items.len) Common.invariant("ConstStore function id is out of range"); const fn_value = store_view.const_store.getFn(@enumFromInt(raw)); if (fn_value.fn_def == .parser_runtime) { - return try self.restoreConstParserRuntimeFnExpr(store_view, fn_value, ty); + return try self.restoreConstParserRuntimeFnExpr(store_view, fn_value, ty, static_data_const_locator); } if (fn_value.fn_def == .encoder_for_runtime) { - return try self.restoreConstEncoderForRuntimeFnExpr(store_view, fn_value, ty); + return try self.restoreConstEncoderForRuntimeFnExpr(store_view, fn_value, ty, static_data_const_locator); } if (fn_value.captures.len == 0) { const template = try self.restoredConstFnTemplateToMono(store_view, fn_id, fn_value, ty); @@ -3706,7 +3843,10 @@ const Builder = struct { initialized += 1; } for (fn_value.captures, 0..) |capture, index| { - captures[index].value = try fn_ctx.restoreConstNodeAtType(store_view, fn_view, capture.value, captures[index].ty); + captures[index].value = if (static_data_const_locator) |const_locator| + try fn_ctx.restoredStaticDataCandidateNode(store_view, fn_view, capture.value, captures[index].ty, const_locator, checkedBinderType(fn_view, constCaptureBinder(capture.id)), .allow) + else + try fn_ctx.restoreConstNodeAtType(store_view, fn_view, capture.value, captures[index].ty); } var template = try self.constFnTemplateToMono(fn_value, ty); switch (template.fn_def) { @@ -3766,6 +3906,7 @@ const Builder = struct { store_view: ModuleView, fn_value: check.ConstStore.ConstFn, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.ExprId { const runtime = switch (fn_value.fn_def) { .parser_runtime => |runtime| runtime, @@ -3784,7 +3925,7 @@ const Builder = struct { defer fn_ctx.deinit(); fn_ctx.current_fn_key = fn_value.source_fn_key; - var source_captures = try self.bindConstSourceCaptures(&fn_ctx, store_view, fn_view, fn_value); + var source_captures = try self.bindConstSourceCaptures(&fn_ctx, store_view, fn_view, fn_value, static_data_const_locator); defer self.releaseConstSourceCaptures(&fn_ctx, &source_captures); const expr = fn_view.bodies.expr(runtime.expr); @@ -3817,7 +3958,10 @@ const Builder = struct { fn_ctx.setLocalCaptureId(local, parserEncodingCaptureId()); encoding_let = .{ .local = local, - .value = try fn_ctx.restoreConstNodeAtType(store_view, fn_view, node, arg_tys[0]), + .value = if (static_data_const_locator) |const_locator| + try fn_ctx.restoreConstNodeAtTypeWithStaticRoot(store_view, fn_view, node, arg_tys[0], const_locator) + else + try fn_ctx.restoreConstNodeAtType(store_view, fn_view, node, arg_tys[0]), }; break :blk try fn_ctx.localExpr(local, arg_tys[0]); } else try fn_ctx.lowerDispatchOperandAtType(plan_args[0], arg_tys[0]); @@ -3874,6 +4018,7 @@ const Builder = struct { store_view: ModuleView, fn_value: check.ConstStore.ConstFn, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.ExprId { const runtime = switch (fn_value.fn_def) { .encoder_for_runtime => |runtime| runtime, @@ -3892,7 +4037,7 @@ const Builder = struct { defer fn_ctx.deinit(); fn_ctx.current_fn_key = fn_value.source_fn_key; - var source_captures = try self.bindConstSourceCaptures(&fn_ctx, store_view, fn_view, fn_value); + var source_captures = try self.bindConstSourceCaptures(&fn_ctx, store_view, fn_view, fn_value, static_data_const_locator); defer self.releaseConstSourceCaptures(&fn_ctx, &source_captures); const expr = fn_view.bodies.expr(runtime.expr); @@ -3927,7 +4072,10 @@ const Builder = struct { fn_ctx.setLocalCaptureId(local, encoderForEncodingCaptureId()); encoding_let = .{ .local = local, - .value = try fn_ctx.restoreConstNodeAtType(store_view, fn_view, node, arg_tys[0]), + .value = if (static_data_const_locator) |const_locator| + try fn_ctx.restoreConstNodeAtTypeWithStaticRoot(store_view, fn_view, node, arg_tys[0], const_locator) + else + try fn_ctx.restoreConstNodeAtType(store_view, fn_view, node, arg_tys[0]), }; break :blk try fn_ctx.localExpr(local, arg_tys[0]); } else try fn_ctx.lowerDispatchOperandAtType(plan_args[0], arg_tys[0]); @@ -4010,6 +4158,17 @@ const Builder = struct { type_view: ModuleView, node: checked.ConstNodeId, ty: Type.TypeId, + ) Allocator.Error!Ast.ExprId { + return try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, node, ty, null); + } + + fn restoreConstNodeAtTypeWithStaticRoot( + self: *Builder, + store_view: ModuleView, + type_view: ModuleView, + node: checked.ConstNodeId, + ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.ExprId { const address = ConstExprAddress{ .store_module_bytes = store_view.key.bytes, @@ -4017,20 +4176,22 @@ const Builder = struct { .node = @intFromEnum(node), .mono_ty = @intFromEnum(ty), }; - if (self.const_expr_cache.get(address)) |existing| return existing; + if (static_data_const_locator == null) { + if (self.const_expr_cache.get(address)) |existing| return existing; + } const value = store_view.const_store.get(node); switch (value) { .fn_value => |fn_id| { - const expr = try self.restoreConstFnExpr(store_view, type_view, fn_id, ty); - try self.const_expr_cache.put(address, expr); + const expr = try self.restoreConstFnExpr(store_view, type_view, fn_id, ty, static_data_const_locator); + if (static_data_const_locator == null) try self.const_expr_cache.put(address, expr); return expr; }, else => {}, } - const data = try self.restoreConstData(store_view, type_view, value, ty); + const data = try self.restoreConstData(store_view, type_view, value, ty, static_data_const_locator); const expr = try self.program.addExpr(.{ .ty = ty, .data = data }); - try self.const_expr_cache.put(address, expr); + if (static_data_const_locator == null) try self.const_expr_cache.put(address, expr); return expr; } @@ -4040,6 +4201,7 @@ const Builder = struct { type_view: ModuleView, value: checked.ConstValue, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.ExprData { return switch (value) { .pending => Common.invariant("pending ConstStore node reached Monotype restore"), @@ -4055,21 +4217,21 @@ const Builder = struct { str.offset, str.len, ) }, - .list => |items| .{ .list = try self.restoreConstList(store_view, type_view, ty, items) }, + .list => |items| .{ .list = try self.restoreConstList(store_view, type_view, ty, items, static_data_const_locator) }, .box => |payload| blk: { - const child = try self.restoreConstNodeAtType(store_view, type_view, payload, self.constBoxPayloadType(ty)); + const child = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, payload, self.constBoxPayloadType(ty), static_data_const_locator); break :blk .{ .low_level = .{ .op = .box_box, .args = try self.program.addExprSpan(&.{child}), } }; }, - .tuple => |items| .{ .tuple = try self.restoreConstTuple(store_view, type_view, ty, items) }, - .record => |items| .{ .record = try self.restoreConstRecord(store_view, type_view, ty, items) }, + .tuple => |items| .{ .tuple = try self.restoreConstTuple(store_view, type_view, ty, items, static_data_const_locator) }, + .record => |items| .{ .record = try self.restoreConstRecord(store_view, type_view, ty, items, static_data_const_locator) }, .tag => |tag| .{ .tag = .{ .name = try self.program.names.internTagLabel(tag.tag_name), - .payloads = try self.restoreConstTagPayloads(store_view, type_view, ty, tag), + .payloads = try self.restoreConstTagPayloads(store_view, type_view, ty, tag, static_data_const_locator), } }, - .nominal => |nominal| .{ .nominal = try self.restoreConstNodeAtType(store_view, type_view, nominal.backing, self.namedBackingType(ty) orelse ty) }, + .nominal => |nominal| .{ .nominal = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, nominal.backing, self.namedBackingType(ty) orelse ty, static_data_const_locator) }, .fn_value => Common.invariant("ConstStore function value must be restored as an expression"), }; } @@ -4080,12 +4242,13 @@ const Builder = struct { type_view: ModuleView, ty: Type.TypeId, items: []const checked.ConstNodeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.Span(Ast.ExprId) { const elem_ty = self.constListElemType(ty); const lowered = try self.allocator.alloc(Ast.ExprId, items.len); defer self.allocator.free(lowered); for (items, 0..) |item, index| { - lowered[index] = try self.restoreConstNodeAtType(store_view, type_view, item, elem_ty); + lowered[index] = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, item, elem_ty, static_data_const_locator); } return try self.program.addExprSpan(lowered); } @@ -4096,6 +4259,7 @@ const Builder = struct { type_view: ModuleView, ty: Type.TypeId, items: []const checked.ConstNodeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.Span(Ast.ExprId) { const item_span = self.tupleItemSpan(ty); const item_count: usize = @intCast(item_span.len); @@ -4105,7 +4269,7 @@ const Builder = struct { for (items, 0..) |item, index| { const item_tys = self.program.types.span(item_span); const item_ty = GuardedList.at(item_tys, index); - lowered[index] = try self.restoreConstNodeAtType(store_view, type_view, item, item_ty); + lowered[index] = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, item, item_ty, static_data_const_locator); } return try self.program.addExprSpan(lowered); } @@ -4116,6 +4280,7 @@ const Builder = struct { type_view: ModuleView, ty: Type.TypeId, items: []const checked.ConstNodeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.Span(Ast.FieldExpr) { const field_span = self.recordFieldsSpan(ty); const field_count: usize = @intCast(field_span.len); @@ -4127,7 +4292,7 @@ const Builder = struct { const field = GuardedList.at(fields, index); lowered[index] = .{ .name = field.name, - .value = try self.restoreConstNodeAtType(store_view, type_view, item, field.ty), + .value = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, item, field.ty, static_data_const_locator), }; } return try self.program.addFieldExprSpan(lowered); @@ -4139,6 +4304,7 @@ const Builder = struct { type_view: ModuleView, ty: Type.TypeId, tag: anytype, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!Ast.Span(Ast.ExprId) { const mono_tag_name = try self.program.names.internTagLabel(tag.tag_name); const payload_span = self.tagPayloadSpan(ty, mono_tag_name); @@ -4149,7 +4315,7 @@ const Builder = struct { for (tag.payloads, 0..) |payload, index| { const payload_tys = self.program.types.span(payload_span); const payload_ty = GuardedList.at(payload_tys, index); - lowered[index] = try self.restoreConstNodeAtType(store_view, type_view, payload, payload_ty); + lowered[index] = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, payload, payload_ty, static_data_const_locator); } return try self.program.addExprSpan(lowered); } @@ -4811,6 +4977,11 @@ const DraftReturn = struct { target: DraftTypeCell, }; +const DraftStaticDataCandidate = struct { + static_data: Common.StaticDataId, + runtime_expr: DraftExprId, +}; + const DraftExpr = struct { ty: DraftTypeCell, data: DraftExprData, @@ -4832,6 +5003,7 @@ const DraftExprData = union(enum(u8)) { dec_lit: builtins.dec.RocDec, str_lit: DraftStringLiteralId, bytes_lit: DraftStringLiteralId, + static_data_candidate: DraftStaticDataCandidate, list: DraftSpan(DraftExprId), tuple: DraftSpan(DraftExprId), record: DraftSpan(DraftFieldExpr), @@ -5714,6 +5886,10 @@ const BodyDraftStore = struct { .dec_lit => |value| .{ .dec_lit = value }, .str_lit => |literal| .{ .str_lit = ids.stringLiteral(literal) }, .bytes_lit => |literal| .{ .bytes_lit = ids.stringLiteral(literal) }, + .static_data_candidate => |candidate| .{ .static_data_candidate = .{ + .static_data = candidate.static_data, + .runtime_expr = ids.expr(candidate.runtime_expr), + } }, .list => |span| .{ .list = ids.exprSpan(span) }, .tuple => |span| .{ .tuple = ids.exprSpan(span) }, .record => |span| .{ .record = ids.fieldExprSpan(span) }, @@ -6360,7 +6536,7 @@ const BodyContext = struct { fn addExpr(self: *BodyContext, expr: BodyExpr) Allocator.Error!DraftExprId { return try self.draft.addExprWithSource( - .{ .ty = try self.draftTypeCell(expr.ty), .data = expr.data }, + .{ .ty = try self.draftTypeCellPreservingGenerated(expr.ty), .data = expr.data }, self.builder.program.current_loc, self.builder.program.current_region, ); @@ -6375,7 +6551,14 @@ const BodyContext = struct { } fn addPat(self: *BodyContext, pat: BodyPat) Allocator.Error!DraftPatId { - return try self.draft.addPat(.{ .ty = try self.draftTypeCell(pat.ty), .data = pat.data }); + return try self.draft.addPat(.{ .ty = try self.draftTypeCellPreservingGenerated(pat.ty), .data = pat.data }); + } + + fn draftTypeCellPreservingGenerated(self: *BodyContext, ty: Type.TypeId) Allocator.Error!DraftTypeCell { + if (try self.builder.monoTypeHasGeneratedOpaqueEvidence(ty)) { + return DraftTypeCell.fromSealed(ty); + } + return try self.draftTypeCell(ty); } fn addPatWithTypeCell(self: *BodyContext, ty: DraftTypeCell, data: BodyPatData) Allocator.Error!DraftPatId { @@ -6392,7 +6575,7 @@ const BodyContext = struct { ty: Type.TypeId, binder: ?checked.PatternBinderId, ) Allocator.Error!DraftLocalId { - return try self.addLocalWithBinderCell(symbol, try self.draftTypeCell(ty), binder); + return try self.addLocalWithBinderCell(symbol, try self.draftTypeCellPreservingGenerated(ty), binder); } fn addLocalWithBinderCell( @@ -6410,7 +6593,7 @@ const BodyContext = struct { .fn_def = source.fn_def, .source_fn_ty = source.source_fn_ty, .source_fn_key = source.source_fn_key, - .mono_fn_ty = try self.draftTypeCell(source.mono_fn_ty), + .mono_fn_ty = try self.draftTypeCellPreservingGenerated(source.mono_fn_ty), }, }); } @@ -6429,7 +6612,7 @@ const BodyContext = struct { for (values, 0..) |value, index| { draft_values[index] = .{ .local = value.local, - .ty = try self.draftTypeCell(value.ty), + .ty = try self.draftTypeCellPreservingGenerated(value.ty), }; } return try self.draft.addTypedLocalSpan(draft_values); @@ -6513,7 +6696,7 @@ const BodyContext = struct { } fn setLocalType(self: *BodyContext, id: DraftLocalId, ty: Type.TypeId) Allocator.Error!void { - self.draft.setLocalType(id, try self.draftTypeCell(ty)); + self.draft.setLocalType(id, try self.draftTypeCellPreservingGenerated(ty)); } fn bindLocalName(self: *BodyContext, local: DraftLocalId, binder: checked.PatternBinderId) Allocator.Error!void { @@ -6920,7 +7103,12 @@ const BodyContext = struct { while (binder_iter.next()) |entry| { const local = entry.value_ptr.*; const local_ty = self.localTypeCell(local); - try self.constrainTypeToCell(checkedBinderType(self.view, entry.key_ptr.*), local_ty); + const active_local_ty = try self.activeTypeFromCell(local_ty); + if (try self.builder.monoTypeHasGeneratedOpaqueEvidence(active_local_ty)) { + try self.constrainTypeToMono(checkedBinderType(self.view, entry.key_ptr.*), active_local_ty); + } else { + try self.constrainTypeToCell(checkedBinderType(self.view, entry.key_ptr.*), local_ty); + } } } @@ -7168,6 +7356,7 @@ const BodyContext = struct { } return false; }, + .static_data_candidate => |candidate| return try self.exprDependsOnFreeLocalInner(candidate.runtime_expr, target, bound), .nominal, .dbg, .expect, @@ -7444,7 +7633,7 @@ const BodyContext = struct { ) Allocator.Error!void { self.builder.constrain_depth += 1; defer self.builder.constrain_depth -= 1; - try self.graph.unify(try self.instNode(checked_ty), try self.graph.importMono(mono_ty)); + try self.graph.unify(try self.instNode(checked_ty), try self.graph.importMono(try self.publicOpaqueUnificationType(mono_ty))); if (self.builder.constrain_depth == 1) try self.graph.drainDirty(); } @@ -7737,7 +7926,7 @@ const BodyContext = struct { .named_type = .{ .module = self.builder.declaredModuleForNominal(self.view, nominal), .ty = checked_ty }, .def = try self.builder.typeDef(self.view, nominal.origin_module, nominal.name, nominal.source_decl), .kind = if (nominal.is_opaque) .@"opaque" else .nominal, - .builtin_owner = builtinOwner(nominal.builtin), + .builtin_owner = self.builder.builtinOwnerForNominal(self.view, nominal), .args = args, .backing = backing, .declared_order = try self.instDeclaredOrderForNominal(nominal), @@ -7953,10 +8142,10 @@ const BodyContext = struct { }; }, .hosted_lambda => Common.invariant("hosted lambda template must lower through hosted metadata, not source lambda body"), - else => .{ + else => LoweredTemplateBody{ .args = .empty(), .body = try self.lowerExprAtType(body.root_expr, ret_ty), - .ret = try self.draftTypeCell(ret_ty), + .ret = try self.draftTypeCellPreservingGenerated(ret_ty), }, }; }, @@ -7981,7 +8170,7 @@ const BodyContext = struct { .numeral_conversion, .quote_conversion => return .{ .args = .empty(), .body = try self.lowerNumeralRootBody(wrapper.body_expr, ret_ty), - .ret = try self.draftTypeCell(ret_ty), + .ret = try self.draftTypeCellPreservingGenerated(ret_ty), }, .constant, .hoisted_constant, @@ -7989,10 +8178,10 @@ const BodyContext = struct { .expect, => {}, } - return .{ + return LoweredTemplateBody{ .args = .empty(), .body = try self.lowerComptimeRootExprAtType(wrapper.body_expr, ret_ty), - .ret = try self.draftTypeCell(ret_ty), + .ret = try self.draftTypeCellPreservingGenerated(ret_ty), }; }, .intrinsic_wrapper => |wrapper_id| { @@ -8018,7 +8207,7 @@ const BodyContext = struct { return .{ .args = try self.addTypedLocalSpan(&.{typed_arg}), .body = body, - .ret = try self.draftTypeCell(ret_ty), + .ret = try self.draftTypeCellPreservingGenerated(ret_ty), }; } @@ -8116,8 +8305,11 @@ const BodyContext = struct { const body_ty = try self.exprType(body); try self.graph.unify(try self.graph.importMono(ret_ty), try self.graph.importMono(body_ty)); try self.graph.drainDirty(); - const solved_ret_ty = try self.activeTypeFromType(ret_ty); - self.draft.exprs.items[@intFromEnum(body)].ty = try self.draftTypeCell(solved_ret_ty); + const solved_ret_ty = if (try self.builder.monoTypeHasGeneratedOpaqueEvidence(ret_ty)) + ret_ty + else + try self.activeTypeFromType(ret_ty); + self.draft.exprs.items[@intFromEnum(body)].ty = try self.draftTypeCellPreservingGenerated(solved_ret_ty); for (args) |*arg| { arg.ty = try self.localType(arg.local); @@ -8126,7 +8318,7 @@ const BodyContext = struct { return .{ .args = try self.addTypedLocalSpan(args), .body = body, - .ret = try self.draftTypeCell(solved_ret_ty), + .ret = try self.draftTypeCellPreservingGenerated(solved_ret_ty), }; } @@ -8293,10 +8485,7 @@ const BodyContext = struct { if (self.current_entry_root) |root| { return entry.root == root; } - - const wrapper = self.view.entry_wrappers.lookupByRoot(entry.root) orelse - Common.invariant("hoisted const root had no checked entry wrapper"); - return names.procedureTemplateRefEql(wrapper.template, self.owner_template); + return false; } fn restoredHoistedConstAtType( @@ -8315,7 +8504,15 @@ const BodyContext = struct { defer self.builder.program.current_region = saved_region; self.builder.program.current_loc = try self.sourceLocFor(source_region); self.builder.program.current_region = source_region; - break :blk try self.restoreConstNodeAtType(self.view, self.view, stored.node, ty); + break :blk try self.restoredStaticDataCandidateNode( + self.view, + self.view, + stored.node, + ty, + entry.const_ref, + entry.checked_type, + .disallow, + ); }, .eval_template => |eval| try self.lowerConstEvalTemplateUse(self.view, eval, ty, source_region, entry.root), .reserved => Common.invariant("reserved hoisted const template reached Monotype"), @@ -8343,12 +8540,12 @@ const BodyContext = struct { self: *BodyContext, selected: checked.SelectedHoistedConstUse, ) checked.HoistedConstEntry { - const const_ref = selected.const_use.const_ref; - const module_id = checked.constModuleId(const_ref); + const const_locator = selected.const_use.const_ref; + const module_id = checked.constModuleId(const_locator); if (!moduleBytesEqual(module_id.bytes, self.view.key.bytes)) { Common.invariant("selected hoisted local const ref referenced a different checked module"); } - const hoisted = switch (const_ref.owner) { + const hoisted = switch (const_locator.owner) { .hoisted_expr => |owner| owner, .top_level_binding => Common.invariant("selected hoisted local const ref did not reference a hoisted const"), }; @@ -8793,7 +8990,7 @@ const BodyContext = struct { const template = view.callable_eval_templates.templates[raw]; const root = view.compile_time_roots.root(template.root); return switch (root.payload) { - .fn_value => |fn_id| try self.restoreConstFn(view, fn_id, mono_fn_ty), + .fn_value => |fn_id| try self.restoreConstFn(view, fn_id, mono_fn_ty, null), .pending => try self.lowerPendingCallableEvalBindingValue(view, template, root, mono_fn_ty), else => Common.invariant("callable eval binding root did not output a callable value"), }; @@ -8857,6 +9054,83 @@ const BodyContext = struct { } } + fn builtinProcedureTextForResolvedTarget(self: *BodyContext, target: checked.ResolvedValueId) ?[]const u8 { + const raw = @intFromEnum(target); + if (raw >= self.view.resolved_refs.records.len) { + Common.invariant("checked builtin target is outside resolved value table"); + } + const record = self.view.resolved_refs.records[raw]; + return switch (record.ref) { + .top_level_proc, + .imported_proc, + .hosted_proc, + .promoted_top_level_proc, + => |proc| self.builtinProcedureTextForUse(proc), + .platform_required_proc => |proc| self.builtinProcedureTextForUse(proc.procedure), + else => null, + }; + } + + fn builtinProcedureTextForUse(self: *BodyContext, proc: checked.ProcedureUseTemplate) ?[]const u8 { + return switch (proc.binding) { + .top_level => |top_level| blk: { + const view = self.builder.moduleForId(checked.topLevelProcedureModuleId(top_level)); + if (view.module_env.module_role != .builtin) break :blk null; + const binding = view.top_level_procedure_bindings.get(top_level.binding); + break :blk builtinProcedureTextForBindingBody(view, binding.body); + }, + .imported => |imported| blk: { + const view = self.builder.moduleForId(checked.importedProcedureModuleId(imported)); + if (view.module_env.module_role != .builtin) break :blk null; + for (view.exported_procedure_bindings.bindings) |binding| { + if (binding.binding.def == imported.def and binding.binding.pattern == imported.pattern) { + break :blk builtinProcedureTextForImportedBindingBody(view, binding.body); + } + } + Common.invariant("imported builtin procedure binding was not exported by its checked module"); + }, + .hosted => |hosted| blk: { + const view = self.builder.moduleForId(moduleIdFromDigest(names.procTemplateModuleDigest(hosted.template))); + if (view.module_env.module_role != .builtin) break :blk null; + break :blk builtinProcedureTextForTemplate(view, hosted.template); + }, + .platform_required => |required| blk: { + const view = self.builder.moduleForId(checked.requiredProcedureModuleId(required)); + if (view.module_env.module_role != .builtin) break :blk null; + const binding = view.top_level_procedure_bindings.get(required.procedure_binding); + break :blk builtinProcedureTextForBindingBody(view, binding.body); + }, + }; + } + + fn builtinProcedureTextForBindingBody(view: ModuleView, body: checked.ProcedureBindingBody) ?[]const u8 { + return switch (body) { + .direct_template => |direct| builtinProcedureTextForDirectTemplate(view, direct), + .callable_eval_template => null, + }; + } + + fn builtinProcedureTextForImportedBindingBody(view: ModuleView, body: checked.ImportedProcedureBindingBody) ?[]const u8 { + return switch (body) { + .direct_template => |direct| builtinProcedureTextForDirectTemplate(view, direct), + .callable_eval_template => null, + }; + } + + fn builtinProcedureTextForDirectTemplate(view: ModuleView, direct: checked.DirectProcedureBinding) ?[]const u8 { + const template = switch (direct.template) { + .checked => |template| template, + .lifted, .synthetic => return null, + }; + return builtinProcedureTextForTemplate(view, template); + } + + fn builtinProcedureTextForTemplate(view: ModuleView, template: names.ProcTemplate) ?[]const u8 { + const proc_base = view.names.procBase(template.proc_base); + const export_name = proc_base.export_name orelse return null; + return view.names.exportNameText(export_name); + } + fn parseIntrinsicForBuiltinProcedureUse(self: *BodyContext, proc: checked.ProcedureUseTemplate) ?ParseIntrinsic { const top_level = switch (proc.binding) { .top_level => |top_level| top_level, @@ -8900,6 +9174,62 @@ const BodyContext = struct { return null; } + fn isBuiltinIterNextText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.next"); + } + + fn isBuiltinIterCustomText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.custom"); + } + + fn isBuiltinIterSingleText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.single"); + } + + fn isBuiltinIterMapText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.map"); + } + + fn isBuiltinIterKeepIfText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.keep_if"); + } + + fn isBuiltinIterDropIfText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.drop_if"); + } + + fn isBuiltinIterTakeFirstText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.take_first"); + } + + fn isBuiltinIterDropFirstText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.drop_first"); + } + + fn isBuiltinIterConcatText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.concat"); + } + + fn isBuiltinIterAppendText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.append"); + } + + fn isBuiltinExclusiveRangeText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.exclusive_range"); + } + + fn isBuiltinInclusiveRangeText(text: []const u8) bool { + return Ident.textEql(text, "Builtin.Iter.inclusive_range"); + } + + fn isBuiltinIterFromStepText(text: []const u8) bool { + return Ident.textEql(text, "iter_from_step") or Ident.textEql(text, "Builtin.iter_from_step"); + } + + fn isBuiltinRangeDoneText(text: []const u8) bool { + return Ident.textEql(text, "range_done") or Ident.textEql(text, "Builtin.range_done"); + } + fn lowerFieldNamesRenameFieldNames( self: *BodyContext, args: []const checked.CheckedExprId, @@ -9661,12 +9991,36 @@ const BodyContext = struct { return self.generatedParseTagUnionSpecBackingInfo(spec_ty) != null; } + fn isGeneratedIteratorEvidenceType(self: *BodyContext, ty: Type.TypeId) bool { + return switch (self.builder.program.types.get(ty)) { + .named => |named| switch (named.def.iterator_representation) { + .minted, .forced_dynamic => switch (named.builtin_owner orelse return false) { + .iter, .stream => true, + else => false, + }, + .none => false, + }, + else => false, + }; + } + + fn isForcedDynamicIteratorType(self: *BodyContext, ty: Type.TypeId) bool { + return switch (self.builder.program.types.get(ty)) { + .named => |named| named.def.iterator_representation == .forced_dynamic, + else => false, + }; + } + fn isGeneratedOpaqueEvidenceType(self: *BodyContext, ty: Type.TypeId) bool { - return self.isGeneratedFieldNamesEvidenceType(ty) or self.isGeneratedParseTagUnionSpecEvidenceType(ty); + return self.isGeneratedFieldNamesEvidenceType(ty) or + self.isGeneratedParseTagUnionSpecEvidenceType(ty) or + self.isGeneratedIteratorEvidenceType(ty); } fn isGeneratedSpecializationEvidenceType(self: *BodyContext, ty: Type.TypeId) bool { - return self.isGeneratedFieldNamesEvidenceType(ty) or self.isGeneratedParseTagUnionSpecEvidenceType(ty); + return self.isGeneratedFieldNamesEvidenceType(ty) or + self.isGeneratedParseTagUnionSpecEvidenceType(ty) or + self.isGeneratedIteratorEvidenceType(ty); } fn sealedGeneratedOpaqueEvidenceType(self: *BodyContext, ty: Type.TypeId) Allocator.Error!Type.TypeId { @@ -9674,38 +10028,266 @@ const BodyContext = struct { return try self.graph.sealType(ty); } - fn functionHasGeneratedOpaqueEvidence(self: *BodyContext, fn_ty: Type.TypeId) bool { + fn functionHasGeneratedOpaqueEvidence(self: *BodyContext, fn_ty: Type.TypeId) Allocator.Error!bool { const function = self.builder.functionShape(fn_ty, "generated opaque evidence check requested for a non-function type"); const args = self.builder.program.types.span(function.args); for (0..GuardedList.borrowLen(args)) |index| { const arg = GuardedList.at(args, index); - if (self.isGeneratedOpaqueEvidenceType(arg)) return true; + if (try self.builder.monoTypeHasGeneratedOpaqueEvidence(arg)) return true; } - return self.isGeneratedOpaqueEvidenceType(function.ret); + return try self.builder.monoTypeHasGeneratedOpaqueEvidence(function.ret); } fn publicOpaqueUnificationType(self: *BodyContext, ty: Type.TypeId) Allocator.Error!Type.TypeId { - if (!self.isGeneratedOpaqueEvidenceType(ty)) return ty; + if (!try self.builder.monoTypeHasGeneratedOpaqueEvidence(ty)) return ty; + var cache = std.AutoHashMap(Type.TypeId, Type.TypeId).init(self.allocator); + defer cache.deinit(); + return try self.publicOpaqueUnificationTypeInner(ty, &cache); + } + + fn publicOpaqueUnificationTypeInner( + self: *BodyContext, + ty: Type.TypeId, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + ) Allocator.Error!Type.TypeId { + if (cache.get(ty)) |cached| return cached; + if (!try self.builder.monoTypeHasGeneratedOpaqueEvidence(ty)) return ty; + return switch (self.builder.program.types.get(ty)) { - .named => |named| try self.builder.program.types.add(.{ .named = .{ - .named_type = named.named_type, - .def = named.def, - .kind = named.kind, - .builtin_owner = named.builtin_owner, - .args = named.args, - .backing = .{ - .ty = try self.builder.program.types.add(.{ .record = .empty() }), - .use = if (named.backing) |backing| backing.use else .runtime_layout_only, - }, - .declared_order = named.declared_order, - } }), - else => ty, + .primitive, + .erased, + .zst, + => ty, + .named => |named| blk: { + if (self.isGeneratedIteratorEvidenceType(ty)) { + break :blk try self.publicIteratorUnificationType(ty, named, cache); + } + if (self.isGeneratedOpaqueEvidenceType(ty)) { + break :blk try self.publicOpaqueEvidenceNamedType(named); + } + + var changed = false; + const args = try GuardedList.dupe(self.allocator, Type.TypeId, self.builder.program.types.span(named.args)); + defer self.allocator.free(args); + const public_args = try self.allocator.alloc(Type.TypeId, args.len); + defer self.allocator.free(public_args); + for (args, 0..) |arg, index| { + public_args[index] = try self.publicOpaqueUnificationTypeInner(arg, cache); + if (public_args[index] != arg) changed = true; + } + + const public_backing: ?Type.NamedBacking = if (named.backing) |backing| backing: { + const public_ty = try self.publicOpaqueUnificationTypeInner(backing.ty, cache); + if (public_ty != backing.ty) changed = true; + break :backing .{ .ty = public_ty, .use = backing.use }; + } else null; + + const declared_order = try GuardedList.dupe(self.allocator, Type.DeclaredField, self.builder.program.types.declaredFieldSpan(named.declared_order)); + defer self.allocator.free(declared_order); + var public_declared = try self.allocator.alloc(Type.DeclaredField, declared_order.len); + defer self.allocator.free(public_declared); + for (declared_order, 0..) |field, index| { + public_declared[index] = switch (field) { + .named => |name| .{ .named = name }, + .padding => |padding| padding: { + const public_padding = try self.publicOpaqueUnificationTypeInner(padding, cache); + if (public_padding != padding) changed = true; + break :padding .{ .padding = public_padding }; + }, + }; + } + + if (!changed) break :blk ty; + const out = try self.builder.program.types.add(.{ .named = .{ + .named_type = named.named_type, + .def = named.def, + .kind = named.kind, + .builtin_owner = named.builtin_owner, + .args = try self.builder.program.types.addSpan(public_args), + .backing = public_backing, + .declared_order = try self.builder.program.types.addDeclaredFields(public_declared), + } }); + try cache.put(ty, out); + break :blk out; + }, + .record => |fields_span| blk: { + const fields = try GuardedList.dupe(self.allocator, Type.Field, self.builder.program.types.fieldSpan(fields_span)); + defer self.allocator.free(fields); + var changed = false; + const public_fields = try self.allocator.alloc(Type.Field, fields.len); + defer self.allocator.free(public_fields); + for (fields, 0..) |field, index| { + const public_ty = try self.publicOpaqueUnificationTypeInner(field.ty, cache); + public_fields[index] = .{ .name = field.name, .ty = public_ty }; + if (public_ty != field.ty) changed = true; + } + if (!changed) break :blk ty; + const out = try self.builder.program.types.add(.{ + .record = try self.builder.program.types.addRecordFields(&self.builder.program.names, public_fields), + }); + try cache.put(ty, out); + break :blk out; + }, + .tuple => |items_span| blk: { + const items = try GuardedList.dupe(self.allocator, Type.TypeId, self.builder.program.types.span(items_span)); + defer self.allocator.free(items); + var changed = false; + const public_items = try self.allocator.alloc(Type.TypeId, items.len); + defer self.allocator.free(public_items); + for (items, 0..) |item, index| { + public_items[index] = try self.publicOpaqueUnificationTypeInner(item, cache); + if (public_items[index] != item) changed = true; + } + if (!changed) break :blk ty; + const out = try self.builder.program.types.add(.{ + .tuple = try self.builder.program.types.addSpan(public_items), + }); + try cache.put(ty, out); + break :blk out; + }, + .tag_union => |tags_span| blk: { + const tags = try GuardedList.dupe(self.allocator, Type.Tag, self.builder.program.types.tagSpan(tags_span)); + defer self.allocator.free(tags); + var changed = false; + const public_tags = try self.allocator.alloc(Type.Tag, tags.len); + defer self.allocator.free(public_tags); + for (tags, 0..) |tag, tag_index| { + const payloads = try GuardedList.dupe(self.allocator, Type.TypeId, self.builder.program.types.span(tag.payloads)); + defer self.allocator.free(payloads); + const public_payloads = try self.allocator.alloc(Type.TypeId, payloads.len); + defer self.allocator.free(public_payloads); + for (payloads, 0..) |payload, payload_index| { + public_payloads[payload_index] = try self.publicOpaqueUnificationTypeInner(payload, cache); + if (public_payloads[payload_index] != payload) changed = true; + } + public_tags[tag_index] = .{ + .name = tag.name, + .checked_name = tag.checked_name, + .payloads = try self.builder.program.types.addSpan(public_payloads), + }; + } + if (!changed) break :blk ty; + const out = try self.builder.program.types.add(.{ + .tag_union = try self.builder.program.types.addTagVariants(&self.builder.program.names, public_tags), + }); + try cache.put(ty, out); + break :blk out; + }, + .list => |elem| blk: { + const public_elem = try self.publicOpaqueUnificationTypeInner(elem, cache); + if (public_elem == elem) break :blk ty; + const out = try self.builder.program.types.add(.{ .list = public_elem }); + try cache.put(ty, out); + break :blk out; + }, + .box => |elem| blk: { + const public_elem = try self.publicOpaqueUnificationTypeInner(elem, cache); + if (public_elem == elem) break :blk ty; + const out = try self.builder.program.types.add(.{ .box = public_elem }); + try cache.put(ty, out); + break :blk out; + }, + .func => |function| blk: { + const args = try GuardedList.dupe(self.allocator, Type.TypeId, self.builder.program.types.span(function.args)); + defer self.allocator.free(args); + var changed = false; + const public_args = try self.allocator.alloc(Type.TypeId, args.len); + defer self.allocator.free(public_args); + for (args, 0..) |arg, index| { + public_args[index] = try self.publicOpaqueUnificationTypeInner(arg, cache); + if (public_args[index] != arg) changed = true; + } + const public_ret = try self.publicOpaqueUnificationTypeInner(function.ret, cache); + if (public_ret != function.ret) changed = true; + if (!changed) break :blk ty; + const out = try self.builder.program.types.add(.{ .func = .{ + .args = try self.builder.program.types.addSpan(public_args), + .ret = public_ret, + } }); + try cache.put(ty, out); + break :blk out; + }, + }; + } + + fn publicIteratorUnificationType( + self: *BodyContext, + generated_ty: Type.TypeId, + named: Type.NamedContent, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + ) Allocator.Error!Type.TypeId { + if (cache.get(generated_ty)) |cached| return cached; + + const Context = struct { + body: *BodyContext, + generated_ty: Type.TypeId, + named: Type.NamedContent, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + + fn fill(ctx: @This(), self_ty: Type.TypeId) Allocator.Error!Type.Content { + try ctx.cache.put(ctx.generated_ty, self_ty); + + var public_def = ctx.named.def; + public_def.generated = null; + public_def.iterator_representation = .none; + public_def.iterator_depth = 0; + const args = ctx.body.builder.program.types.span(ctx.named.args); + if (args.len == 0) Common.invariant("generated iterator evidence had no public item argument"); + const public_item = try ctx.body.publicOpaqueUnificationTypeInner(GuardedList.at(args, 0), ctx.cache); + const public_args = [_]Type.TypeId{public_item}; + + const public_backing: ?Type.NamedBacking = if (ctx.named.backing) |backing| .{ + .ty = try ctx.body.publicOpaqueUnificationTypeInner(backing.ty, ctx.cache), + .use = backing.use, + } else null; + + return .{ .named = .{ + .named_type = ctx.named.named_type, + .def = public_def, + .kind = ctx.named.kind, + .builtin_owner = ctx.named.builtin_owner, + .args = try ctx.body.builder.program.types.addSpan(&public_args), + .backing = public_backing, + .declared_order = ctx.named.declared_order, + } }; + } }; + + return try self.builder.program.types.addRecursive(Context{ + .body = self, + .generated_ty = generated_ty, + .named = named, + .cache = cache, + }, Context.fill); + } + + fn publicOpaqueEvidenceNamedType( + self: *BodyContext, + named: Type.NamedContent, + ) Allocator.Error!Type.TypeId { + var public_def = named.def; + public_def.generated = null; + public_def.iterator_representation = .none; + public_def.iterator_depth = 0; + return try self.builder.program.types.add(.{ .named = .{ + .named_type = named.named_type, + .def = public_def, + .kind = named.kind, + .builtin_owner = named.builtin_owner, + .args = named.args, + .backing = .{ + .ty = try self.builder.program.types.add(.{ .record = .empty() }), + .use = if (named.backing) |backing| backing.use else .runtime_layout_only, + }, + .declared_order = named.declared_order, + } }); } fn publicOpaqueFunctionUnificationType(self: *BodyContext, fn_ty: Type.TypeId) Allocator.Error!Type.TypeId { const function = self.builder.functionShape(fn_ty, "public opaque unification requested for a non-function type"); - const args = self.builder.program.types.span(function.args); + const args = try GuardedList.dupe(self.allocator, Type.TypeId, self.builder.program.types.span(function.args)); + defer self.allocator.free(args); + const public_ret = try self.publicOpaqueUnificationType(function.ret); var changed = false; const public_args = try self.allocator.alloc(Type.TypeId, args.len); defer self.allocator.free(public_args); @@ -9714,49 +10296,964 @@ const BodyContext = struct { public_args[index] = try self.publicOpaqueUnificationType(arg); if (public_args[index] != arg) changed = true; } + if (public_ret != function.ret) changed = true; if (!changed) return fn_ty; return try self.builder.program.types.add(.{ .func = .{ .args = try self.builder.program.types.addSpan(public_args), - .ret = function.ret, + .ret = public_ret, } }); } - fn lowerFieldNamesValueIter( + fn generatedIteratorDirectCallFunctionType( self: *BodyContext, - backing_fields: anytype, - fields_ty: Type.TypeId, - fields_local: DraftLocalId, - field_handle_ty: Type.TypeId, - iter_ty: Type.TypeId, - iter_backing_ty: Type.TypeId, - len_ty: Type.TypeId, - step_fn_ty: Type.TypeId, - step_ret_ty: Type.TypeId, - size_local: ?DraftLocalId, - arg_tys: []const Type.TypeId, - checked_source_ty: checked.CheckedTypeId, - source_expr_id: checked.CheckedExprId, - ) Allocator.Error!DraftExprId { - const fields_backing_ty = self.builder.namedBackingType(fields_ty) orelse - Common.invariant("generated FieldNames value expected a named backing type"); - const info = self.generatedFieldNamesBackingInfoFromBacking(fields_backing_ty) orelse - Common.invariant("generated FieldNames value expected a generated backing type"); - const item_fields = try GuardedList.dupe(self.allocator, Type.Field, info.item_fields); - defer self.allocator.free(item_fields); - if (GuardedList.borrowLen(backing_fields) != item_fields.len) Common.invariant("generated FieldNames iterator arity differed from item count"); - const backing_local = try self.addLocal(self.builder.symbols.fresh(), fields_backing_ty); - const items_expr = try self.addExpr(.{ - .ty = info.items_field.ty, - .data = .{ .field_access = .{ - .receiver = try self.localExpr(backing_local, fields_backing_ty), - .field = info.items_field.name, - } }, - }); - const items_local = try self.addLocal(self.builder.symbols.fresh(), info.items_field.ty); - const body = try self.lowerFieldNamesValueIterFromIndex( - item_fields, - info.items_field.ty, - items_local, + target: checked.ResolvedValueId, + mono_fn_ty: Type.TypeId, + checked_args: []const checked.CheckedExprId, + expected_ret_ty: ?Type.TypeId, + ) Allocator.Error!?Type.TypeId { + const text = self.builtinProcedureTextForResolvedTarget(target) orelse return null; + return try self.generatedIteratorBuiltinFunctionType(text, mono_fn_ty, checked_args, expected_ret_ty); + } + + fn generatedIteratorBuiltinFunctionType( + self: *BodyContext, + text: []const u8, + mono_fn_ty: Type.TypeId, + checked_args: []const checked.CheckedExprId, + expected_ret_ty: ?Type.TypeId, + ) Allocator.Error!?Type.TypeId { + const fn_data = self.builder.functionShape(mono_fn_ty, "iterator generated call target had a non-function type"); + const arg_tys = try GuardedList.dupe(self.allocator, Type.TypeId, self.builder.program.types.span(fn_data.args)); + defer self.allocator.free(arg_tys); + + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (isBuiltinIterFromStepText(text)) { + return try self.generatedIteratorConstructorFunctionType(stable_expected); + } + if (isBuiltinRangeDoneText(text) or + isBuiltinExclusiveRangeText(text) or + isBuiltinInclusiveRangeText(text)) + { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + } + } + + if (expected_ret_ty) |expected| { + if (!try self.builder.monoTypeHasGeneratedOpaqueEvidence(expected)) return null; + } + + if (isBuiltinIterNextText(text)) { + if (checked_args.len != 1 or arg_tys.len != 1) Common.invariant("Iter.next reached Monotype with an unexpected arity"); + if (self.isGeneratedIteratorEvidenceType(arg_tys[0])) { + return try self.functionTypeWithReturn(arg_tys, try self.generatedIteratorStepReturnType(arg_tys[0])); + } + return null; + } + + if (isBuiltinIterCustomText(text)) { + if (checked_args.len != 3 or arg_tys.len != 3) Common.invariant("Iter.custom reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_components = try self.generatedIteratorComponentArgs(stable_expected, 2); + defer self.allocator.free(expected_components); + const stable_args = [_]Type.TypeId{ expected_components[0], arg_tys[1], expected_components[1] }; + return try self.functionTypeWithReturn(&stable_args, stable_expected); + } + } + if (expected_ret_ty == null) { + const components = [_]Type.TypeId{ arg_tys[0], arg_tys[2] }; + return try self.functionTypeWithReturn(arg_tys, try self.generatedIteratorType(.custom, fn_data.ret, &components, try self.callableArgumentEvidenceDigest(checked_args[2]))); + } + } + + if (isBuiltinIterSingleText(text)) { + if (checked_args.len != 1 or arg_tys.len != 1) Common.invariant("Iter.single reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_args = try self.generatedIteratorComponentArgs(stable_expected, 1); + defer self.allocator.free(expected_args); + return try self.functionTypeWithReturn(expected_args, stable_expected); + } + } + if (expected_ret_ty == null) { + const components = [_]Type.TypeId{arg_tys[0]}; + return try self.functionTypeWithReturn(arg_tys, try self.generatedIteratorType(.single, fn_data.ret, &components, null)); + } + } + + if (expected_ret_ty == null and isBuiltinExclusiveRangeText(text)) { + return try self.functionTypeWithReturn(arg_tys, try self.generatedIteratorType(.range_exclusive, fn_data.ret, &.{}, null)); + } + + if (expected_ret_ty == null and isBuiltinInclusiveRangeText(text)) { + return try self.functionTypeWithReturn(arg_tys, try self.generatedIteratorType(.range_inclusive, fn_data.ret, &.{}, null)); + } + + if (isBuiltinIterMapText(text)) { + if (checked_args.len != 2 or arg_tys.len != 2) Common.invariant("Iter.map reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_args = try self.generatedIteratorComponentArgs(stable_expected, 2); + defer self.allocator.free(expected_args); + return try self.functionTypeWithReturn(expected_args, stable_expected); + } + } + if (self.isGeneratedIteratorEvidenceType(arg_tys[0])) { + const stable_iterator = try self.stableGeneratedIteratorEvidenceType(arg_tys[0]); + const stable_args = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + const components = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + return try self.functionTypeWithReturn(&stable_args, try self.generatedIteratorType(.map, fn_data.ret, &components, try self.callableArgumentEvidenceDigest(checked_args[1]))); + } + } + + if (isBuiltinIterKeepIfText(text)) { + if (checked_args.len != 2 or arg_tys.len != 2) Common.invariant("Iter.keep_if reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_args = try self.generatedIteratorComponentArgs(stable_expected, 2); + defer self.allocator.free(expected_args); + return try self.functionTypeWithReturn(expected_args, stable_expected); + } + } + if (self.isGeneratedIteratorEvidenceType(arg_tys[0])) { + const stable_iterator = try self.stableGeneratedIteratorEvidenceType(arg_tys[0]); + const stable_args = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + const components = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + return try self.functionTypeWithReturn(&stable_args, try self.generatedIteratorType(.keep_if, fn_data.ret, &components, try self.callableArgumentEvidenceDigest(checked_args[1]))); + } + } + + if (isBuiltinIterDropIfText(text)) { + if (checked_args.len != 2 or arg_tys.len != 2) Common.invariant("Iter.drop_if reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_args = try self.generatedIteratorComponentArgs(stable_expected, 2); + defer self.allocator.free(expected_args); + return try self.functionTypeWithReturn(expected_args, stable_expected); + } + } + if (self.isGeneratedIteratorEvidenceType(arg_tys[0])) { + const stable_iterator = try self.stableGeneratedIteratorEvidenceType(arg_tys[0]); + const stable_args = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + const components = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + return try self.functionTypeWithReturn(&stable_args, try self.generatedIteratorType(.drop_if, fn_data.ret, &components, try self.callableArgumentEvidenceDigest(checked_args[1]))); + } + } + + if (isBuiltinIterTakeFirstText(text)) { + if (checked_args.len != 2 or arg_tys.len != 2) Common.invariant("Iter.take_first reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_args = try self.generatedIteratorComponentArgs(stable_expected, 2); + defer self.allocator.free(expected_args); + return try self.functionTypeWithReturn(expected_args, stable_expected); + } + } + if (self.isGeneratedIteratorEvidenceType(arg_tys[0])) { + const stable_iterator = try self.stableGeneratedIteratorEvidenceType(arg_tys[0]); + const stable_args = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + const components = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + return try self.functionTypeWithReturn(&stable_args, try self.generatedIteratorType(.take_first, fn_data.ret, &components, null)); + } + } + + if (isBuiltinIterDropFirstText(text)) { + if (checked_args.len != 2 or arg_tys.len != 2) Common.invariant("Iter.drop_first reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_args = try self.generatedIteratorComponentArgs(stable_expected, 2); + defer self.allocator.free(expected_args); + return try self.functionTypeWithReturn(expected_args, stable_expected); + } + } + if (self.isGeneratedIteratorEvidenceType(arg_tys[0])) { + const stable_iterator = try self.stableGeneratedIteratorEvidenceType(arg_tys[0]); + const stable_args = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + const components = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + return try self.functionTypeWithReturn(&stable_args, try self.generatedIteratorType(.drop_first, fn_data.ret, &components, null)); + } + } + + if (isBuiltinIterConcatText(text)) { + if (checked_args.len != 2 or arg_tys.len != 2) Common.invariant("Iter.concat reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_args = try self.generatedIteratorComponentArgs(stable_expected, 2); + defer self.allocator.free(expected_args); + return try self.functionTypeWithReturn(expected_args, stable_expected); + } + } + if (self.isGeneratedIteratorEvidenceType(arg_tys[0]) or self.isGeneratedIteratorEvidenceType(arg_tys[1])) { + const stable_first = try self.stableGeneratedIteratorEvidenceType(arg_tys[0]); + const stable_second = try self.stableGeneratedIteratorEvidenceType(arg_tys[1]); + const stable_args = [_]Type.TypeId{ stable_first, stable_second }; + const components = [_]Type.TypeId{ stable_first, stable_second }; + return try self.functionTypeWithReturn(&stable_args, try self.generatedIteratorType(.concat, fn_data.ret, &components, null)); + } + } + + if (isBuiltinIterAppendText(text)) { + if (checked_args.len != 2 or arg_tys.len != 2) Common.invariant("Iter.append reached Monotype with an unexpected arity"); + if (expected_ret_ty) |expected| { + if (self.isGeneratedIteratorEvidenceType(expected)) { + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected); + if (self.isForcedDynamicIteratorType(stable_expected)) { + return try self.functionTypeWithReturn(arg_tys, stable_expected); + } + const expected_args = try self.generatedIteratorComponentArgs(stable_expected, 2); + defer self.allocator.free(expected_args); + return try self.functionTypeWithReturn(expected_args, stable_expected); + } + } + if (self.isGeneratedIteratorEvidenceType(arg_tys[0])) { + const stable_iterator = try self.stableGeneratedIteratorEvidenceType(arg_tys[0]); + const stable_args = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + const components = [_]Type.TypeId{ stable_iterator, arg_tys[1] }; + return try self.functionTypeWithReturn(&stable_args, try self.generatedIteratorType(.append, fn_data.ret, &components, null)); + } + } + + return null; + } + + fn functionTypeWithReturn( + self: *BodyContext, + arg_tys: []const Type.TypeId, + ret_ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + const copied_args = try self.allocator.dupe(Type.TypeId, arg_tys); + defer self.allocator.free(copied_args); + return try self.builder.program.types.add(.{ .func = .{ + .args = try self.builder.program.types.addSpan(copied_args), + .ret = ret_ty, + } }); + } + + fn stableGeneratedIteratorEvidenceType( + self: *BodyContext, + ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + if (!self.isGeneratedIteratorEvidenceType(ty)) return ty; + if (self.isForcedDynamicIteratorType(ty)) return ty; + const original_digest = self.generatedIteratorEvidenceDigest(ty) orelse + Common.invariant("generated iterator evidence had no generated digest"); + const Context = struct { + body: *BodyContext, + original: Type.TypeId, + original_digest: names.TypeDigest, + + fn fill(ctx: @This(), self_ty: Type.TypeId) Allocator.Error!Type.Content { + var cache = std.AutoHashMap(Type.TypeId, Type.TypeId).init(ctx.body.allocator); + defer cache.deinit(); + try cache.put(ctx.original, self_ty); + return try ctx.body.cloneTypeReplacingGeneratedSelfContent(ctx.original, ctx.original_digest, self_ty, &cache); + } + }; + return try self.builder.program.types.addRecursive(Context{ + .body = self, + .original = ty, + .original_digest = original_digest, + }, Context.fill); + } + + fn generatedIteratorEvidenceDigest(self: *BodyContext, ty: Type.TypeId) ?names.TypeDigest { + return switch (self.builder.program.types.get(ty)) { + .named => |named| if (named.def.iterator_representation == .minted) named.def.generated else null, + else => null, + }; + } + + fn cloneTypeReplacingGeneratedSelf( + self: *BodyContext, + ty: Type.TypeId, + original_digest: names.TypeDigest, + replacement_ty: Type.TypeId, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + ) Allocator.Error!Type.TypeId { + if (cache.get(ty)) |cached| return cached; + if (self.generatedIteratorEvidenceDigest(ty)) |digest| { + if (optionalDigestEql(digest, original_digest)) { + try cache.put(ty, replacement_ty); + return replacement_ty; + } + } + if (self.graph.monoViewNode(ty)) |node| { + const sealed = try self.graph.sealNode(node); + return try self.cloneTypeReplacingGeneratedSelf(sealed, original_digest, replacement_ty, cache); + } + return switch (self.builder.program.types.get(ty)) { + .primitive, + .erased, + .zst, + => ty, + else => blk: { + const CloneContext = struct { + body: *BodyContext, + original: Type.TypeId, + original_digest: names.TypeDigest, + replacement_ty: Type.TypeId, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + + fn fill(ctx: @This(), self_ty: Type.TypeId) Allocator.Error!Type.Content { + try ctx.cache.put(ctx.original, self_ty); + return try ctx.body.cloneTypeReplacingGeneratedSelfContent(ctx.original, ctx.original_digest, ctx.replacement_ty, ctx.cache); + } + }; + const cloned = try self.builder.program.types.addRecursive(CloneContext{ + .body = self, + .original = ty, + .original_digest = original_digest, + .replacement_ty = replacement_ty, + .cache = cache, + }, CloneContext.fill); + break :blk cloned; + }, + }; + } + + fn cloneTypeReplacingGeneratedSelfContent( + self: *BodyContext, + ty: Type.TypeId, + original_digest: names.TypeDigest, + replacement_ty: Type.TypeId, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + ) Allocator.Error!Type.Content { + return switch (self.builder.program.types.get(ty)) { + .primitive => |primitive| .{ .primitive = primitive }, + .erased => |digest| .{ .erased = digest }, + .zst => .zst, + .list => |elem| .{ .list = try self.cloneTypeReplacingGeneratedSelf(elem, original_digest, replacement_ty, cache) }, + .box => |elem| .{ .box = try self.cloneTypeReplacingGeneratedSelf(elem, original_digest, replacement_ty, cache) }, + .tuple => |items| .{ .tuple = try self.cloneTypeSpanReplacingGeneratedSelf(items, original_digest, replacement_ty, cache) }, + .func => |function| .{ .func = .{ + .args = try self.cloneTypeSpanReplacingGeneratedSelf(function.args, original_digest, replacement_ty, cache), + .ret = try self.cloneTypeReplacingGeneratedSelf(function.ret, original_digest, replacement_ty, cache), + } }, + .record => |fields| .{ .record = try self.cloneFieldSpanReplacingGeneratedSelf(fields, original_digest, replacement_ty, cache) }, + .tag_union => |tags| .{ .tag_union = try self.cloneTagSpanReplacingGeneratedSelf(tags, original_digest, replacement_ty, cache) }, + .named => |named| .{ .named = .{ + .named_type = named.named_type, + .def = named.def, + .kind = named.kind, + .builtin_owner = named.builtin_owner, + .args = try self.cloneTypeSpanReplacingGeneratedSelf(named.args, original_digest, replacement_ty, cache), + .backing = if (named.backing) |backing| .{ + .ty = try self.cloneTypeReplacingGeneratedSelf(backing.ty, original_digest, replacement_ty, cache), + .use = backing.use, + } else null, + .declared_order = try self.cloneDeclaredFieldSpanReplacingGeneratedSelf(named.declared_order, original_digest, replacement_ty, cache), + } }, + }; + } + + fn cloneTypeSpanReplacingGeneratedSelf( + self: *BodyContext, + span: Type.Span, + original_digest: names.TypeDigest, + replacement_ty: Type.TypeId, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + ) Allocator.Error!Type.Span { + const items = try GuardedList.dupe(self.allocator, Type.TypeId, self.builder.program.types.span(span)); + defer self.allocator.free(items); + if (items.len == 0) return .empty(); + const cloned = try self.allocator.alloc(Type.TypeId, items.len); + defer self.allocator.free(cloned); + for (items, 0..) |item, index| { + cloned[index] = try self.cloneTypeReplacingGeneratedSelf(item, original_digest, replacement_ty, cache); + } + return try self.builder.program.types.addSpan(cloned); + } + + fn cloneFieldSpanReplacingGeneratedSelf( + self: *BodyContext, + span: Type.Span, + original_digest: names.TypeDigest, + replacement_ty: Type.TypeId, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + ) Allocator.Error!Type.Span { + const fields = try GuardedList.dupe(self.allocator, Type.Field, self.builder.program.types.fieldSpan(span)); + defer self.allocator.free(fields); + if (fields.len == 0) return .empty(); + const cloned = try self.allocator.alloc(Type.Field, fields.len); + defer self.allocator.free(cloned); + for (fields, 0..) |field, index| { + cloned[index] = .{ + .name = field.name, + .ty = try self.cloneTypeReplacingGeneratedSelf(field.ty, original_digest, replacement_ty, cache), + }; + } + return try self.builder.program.types.addRecordFields(&self.builder.program.names, cloned); + } + + fn cloneTagSpanReplacingGeneratedSelf( + self: *BodyContext, + span: Type.Span, + original_digest: names.TypeDigest, + replacement_ty: Type.TypeId, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + ) Allocator.Error!Type.Span { + const tags = try GuardedList.dupe(self.allocator, Type.Tag, self.builder.program.types.tagSpan(span)); + defer self.allocator.free(tags); + if (tags.len == 0) return .empty(); + const cloned = try self.allocator.alloc(Type.Tag, tags.len); + defer self.allocator.free(cloned); + for (tags, 0..) |tag, index| { + cloned[index] = .{ + .name = tag.name, + .checked_name = tag.checked_name, + .payloads = try self.cloneTypeSpanReplacingGeneratedSelf(tag.payloads, original_digest, replacement_ty, cache), + }; + } + return try self.builder.program.types.addTagVariants(&self.builder.program.names, cloned); + } + + fn cloneDeclaredFieldSpanReplacingGeneratedSelf( + self: *BodyContext, + span: Type.Span, + original_digest: names.TypeDigest, + replacement_ty: Type.TypeId, + cache: *std.AutoHashMap(Type.TypeId, Type.TypeId), + ) Allocator.Error!Type.Span { + const fields = try GuardedList.dupe(self.allocator, Type.DeclaredField, self.builder.program.types.declaredFieldSpan(span)); + defer self.allocator.free(fields); + if (fields.len == 0) return .empty(); + const cloned = try self.allocator.alloc(Type.DeclaredField, fields.len); + defer self.allocator.free(cloned); + for (fields, 0..) |field, index| { + cloned[index] = switch (field) { + .named => |name| .{ .named = name }, + .padding => |padding| .{ .padding = try self.cloneTypeReplacingGeneratedSelf(padding, original_digest, replacement_ty, cache) }, + }; + } + return try self.builder.program.types.addDeclaredFields(cloned); + } + + fn generatedIteratorComponentArgs( + self: *BodyContext, + generated_iter_ty: Type.TypeId, + expected_components: usize, + ) Allocator.Error![]Type.TypeId { + const named = switch (self.builder.program.types.get(generated_iter_ty)) { + .named => |named| named, + else => Common.invariant("generated iterator component args requested for a non-named type"), + }; + const args = self.builder.program.types.span(named.args); + if (args.len != expected_components + 1) { + Common.invariant("generated iterator did not contain the expected component count"); + } + const out = try self.allocator.alloc(Type.TypeId, expected_components); + for (0..expected_components) |index| { + out[index] = GuardedList.at(args, index + 1); + } + return out; + } + + fn generatedIteratorConstructorFunctionType( + self: *BodyContext, + generated_iter_ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + const backing_ty = self.builder.namedBackingType(generated_iter_ty) orelse + Common.invariant("generated iterator constructor requested a type without backing"); + const len_field = self.recordFieldByText(backing_ty, "len_if_known"); + const step_field = self.recordFieldByText(backing_ty, "step"); + const args = [_]Type.TypeId{ len_field.ty, step_field.ty }; + return try self.functionTypeWithReturn(&args, generated_iter_ty); + } + + fn generatedIteratorStepReturnType( + self: *BodyContext, + iter_ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + const backing_ty = self.builder.namedBackingType(iter_ty) orelse + Common.invariant("generated iterator next requested a type without backing"); + const step_field = self.recordFieldByText(backing_ty, "step"); + return self.builder.functionShape(step_field.ty, "generated iterator step field had a non-function type").ret; + } + + fn generatedIteratorType( + self: *BodyContext, + kind: GeneratedIteratorKind, + public_iter_ty: Type.TypeId, + components: []const Type.TypeId, + callable_evidence: ?names.TypeDigest, + ) Allocator.Error!Type.TypeId { + // Mint a per-chain internal `Iter` nominal. Reusing the public `Iter` + // definition here is only provenance for public unification and error + // boundaries. The explicit representation tier and generated digest + // separate this chain's nominal identity from public `Iter`, the + // forced-dynamic tier, and sibling chains. This is representation-level + // minting, not SpecConstr. + const public_named = self.publicIteratorNamed(public_iter_ty); + const item_ty = self.iterItemType(public_iter_ty); + const digest = self.generatedIteratorDigest(kind, item_ty, components, callable_evidence); + if (self.builder.generated_iter_types.get(digest.bytes)) |cached| return cached; + + // Depth backstop: a chain nested past the cap becomes the explicit + // forced-dynamic representation instead of minting deeper. Adapters + // over that representation remain forced-dynamic, so a recursively + // constructed chain reaches a type fixed point and specialization + // terminates. A statically bounded chain deeper than the cap takes the + // same representation degradation, never an unbounded type expansion. + var chain_depth: u32 = 1; + for (components) |component| { + chain_depth = @max(chain_depth, self.mintedIteratorChainDepth(component, depth_walk_fuel) + 1); + } + if (chain_depth > max_minted_iterator_chain_depth) return try self.forcedDynamicIteratorType(public_iter_ty); + + const args = try self.allocator.alloc(Type.TypeId, components.len + 1); + defer self.allocator.free(args); + args[0] = item_ty; + @memcpy(args[1..], components); + + const Context = struct { + body: *BodyContext, + public_named: Type.NamedContent, + item_ty: Type.TypeId, + args: []const Type.TypeId, + digest: names.TypeDigest, + chain_depth: u32, + + fn fill(ctx: @This(), self_ty: Type.TypeId) Allocator.Error!Type.Content { + return try ctx.body.generatedIteratorContent(ctx.public_named, ctx.item_ty, ctx.args, ctx.digest, ctx.chain_depth, self_ty); + } + }; + + const context = Context{ + .body = self, + .public_named = public_named, + .item_ty = item_ty, + .args = args, + .digest = digest, + .chain_depth = chain_depth, + }; + const generated = try self.builder.program.types.addRecursive(context, Context.fill); + try self.builder.generated_iter_types.put(digest.bytes, generated); + return generated; + } + + /// Maximum minted-chain depth (source = 1, each adapter +1). Bounding the + /// minted type universe is what guarantees specialization terminates for + /// recursively-constructed chains regardless of call structure: finitely + /// many minted types means finitely many templates, and template dedup + /// closes every recursion. This is deliberately a bound on the TYPES + /// rather than on any call or request path — a recursive construction has + /// many routes into specialization, and capping the type universe covers + /// all of them at the single point where minted types are born. See + /// design.md "Core Principles" on bounded post-check walks: exhaustion + /// errs toward the explicit forced-dynamic representation (a tier + /// degradation on an implausibly deep static chain), never toward + /// divergence. + const max_minted_iterator_chain_depth: u32 = 16; + + /// Recursion budget for the structural depth walk; exhausting it reports + /// the cap (never zero) so an unexpectedly deep or cyclic shape can only + /// over-trigger the backstop, never under-count into divergence. The safe + /// direction here is the opposite of a substitution check's: reporting + /// too SHALLOW a depth would let minting continue past the cap and + /// diverge, so the walk must fail deep, not shallow. + const depth_walk_fuel: u32 = 64; + + /// Chain depth of any minted iterator reachable inside `ty` by value: + /// a minted iterator reads its recorded depth; containers (named args, + /// records, tuples, tag payloads, list/box elements) take the max over + /// their children. Function types contribute nothing (their returns are + /// control flow, not stored data), and named backings are not walked (the + /// public recursive nominal self-references only through its backing). + fn mintedIteratorChainDepth(self: *BodyContext, ty: Type.TypeId, fuel: u32) u32 { + if (fuel == 0) return max_minted_iterator_chain_depth; + return switch (self.builder.program.types.get(ty)) { + .primitive, .erased, .zst, .func => 0, + .named => |named| blk: { + switch (named.def.iterator_representation) { + .minted => break :blk named.def.iterator_depth, + .forced_dynamic => break :blk max_minted_iterator_chain_depth, + .none => {}, + } + var depth: u32 = 0; + const named_args = self.builder.program.types.span(named.args); + for (0..GuardedList.borrowLen(named_args)) |index| { + depth = @max(depth, self.mintedIteratorChainDepth(GuardedList.at(named_args, index), fuel - 1)); + } + break :blk depth; + }, + .record => |fields| blk: { + var depth: u32 = 0; + const field_span = self.builder.program.types.fieldSpan(fields); + for (0..GuardedList.borrowLen(field_span)) |index| { + depth = @max(depth, self.mintedIteratorChainDepth(GuardedList.at(field_span, index).ty, fuel - 1)); + } + break :blk depth; + }, + .tuple => |items| blk: { + var depth: u32 = 0; + const item_span = self.builder.program.types.span(items); + for (0..GuardedList.borrowLen(item_span)) |index| { + depth = @max(depth, self.mintedIteratorChainDepth(GuardedList.at(item_span, index), fuel - 1)); + } + break :blk depth; + }, + .tag_union => |tags| blk: { + var depth: u32 = 0; + const tag_span = self.builder.program.types.tagSpan(tags); + for (0..GuardedList.borrowLen(tag_span)) |tag_index| { + const payload_span = self.builder.program.types.span(GuardedList.at(tag_span, tag_index).payloads); + for (0..GuardedList.borrowLen(payload_span)) |payload_index| { + depth = @max(depth, self.mintedIteratorChainDepth(GuardedList.at(payload_span, payload_index), fuel - 1)); + } + } + break :blk depth; + }, + .list, .box => |element| self.mintedIteratorChainDepth(element, fuel - 1), + }; + } + + fn generatedIteratorDigest( + self: *BodyContext, + kind: GeneratedIteratorKind, + item_ty: Type.TypeId, + components: []const Type.TypeId, + callable_evidence: ?names.TypeDigest, + ) names.TypeDigest { + var hasher = std.crypto.hash.sha2.Sha256.init(.{}); + hasher.update("roc.generated_iterator"); + hasher.update(@tagName(kind)); + self.updateTypeDigest(&hasher, item_ty); + hashU32(&hasher, @intCast(components.len)); + for (components) |component| { + self.updateTypeDigest(&hasher, component); + } + if (callable_evidence) |evidence| { + hasher.update("callable_evidence"); + hasher.update(&evidence.bytes); + } + return .{ .bytes = hasher.finalResult() }; + } + + fn callableArgumentEvidenceDigest( + self: *BodyContext, + expr_id: checked.CheckedExprId, + ) Allocator.Error!?names.TypeDigest { + const expr = self.view.bodies.expr(expr_id); + return switch (expr.data) { + .closure => |closure| try self.closureArgumentEvidenceDigest(expr_id, closure), + .lambda => |lambda| self.lambdaArgumentEvidenceDigest(expr_id, lambda.args.len), + else => null, + }; + } + + fn lambdaArgumentEvidenceDigest( + self: *BodyContext, + expr_id: checked.CheckedExprId, + arg_count: usize, + ) names.TypeDigest { + var hasher = std.crypto.hash.sha2.Sha256.init(.{}); + hasher.update("roc.generated_iterator.callable.lambda"); + hasher.update(self.view.key.bytes[0..]); + hashU32(&hasher, @intFromEnum(expr_id)); + hashU32(&hasher, @intCast(arg_count)); + hashU32(&hasher, 0); + return .{ .bytes = hasher.finalResult() }; + } + + fn closureArgumentEvidenceDigest( + self: *BodyContext, + expr_id: checked.CheckedExprId, + closure: anytype, + ) Allocator.Error!names.TypeDigest { + var hasher = std.crypto.hash.sha2.Sha256.init(.{}); + hasher.update("roc.generated_iterator.callable.closure"); + hasher.update(self.view.key.bytes[0..]); + hashU32(&hasher, @intFromEnum(expr_id)); + hashU32(&hasher, @intFromEnum(closure.lambda)); + hashU32(&hasher, @intCast(closure.captures.len)); + for (closure.captures) |capture| { + hashU32(&hasher, @intFromEnum(capture.capture_id)); + hashU32(&hasher, capture.scope_depth); + const binder = checkedCaptureBinder(self.view, capture.pattern); + const capture_ty = try self.lowerTypeView(checkedBinderType(self.view, binder)); + self.updateTypeDigest(&hasher, capture_ty); + } + return .{ .bytes = hasher.finalResult() }; + } + + fn updateTypeDigest( + self: *BodyContext, + hasher: *std.crypto.hash.sha2.Sha256, + ty: Type.TypeId, + ) void { + const digest = self.builder.program.types.typeDigest(&self.builder.program.names, ty); + hasher.update(&digest.bytes); + } + + fn publicIteratorNamed(self: *BodyContext, ty: Type.TypeId) Type.NamedContent { + return switch (self.builder.program.types.get(ty)) { + .named => |named| blk: { + if (named.builtin_owner) |owner| { + if (owner != .iter) Common.invariant("generated iterator requested a non-Iter public type"); + break :blk named; + } + const type_name = self.builder.program.names.typeNameText(named.def.type_name); + if (!Ident.textEql(type_name, "Builtin.Iter") and !Ident.textEql(type_name, "Iter")) { + Common.invariant("generated iterator requested a non-Iter public type"); + } + var stamped = named; + stamped.builtin_owner = .iter; + break :blk stamped; + }, + else => Common.invariant("generated iterator requested a non-named public type"), + }; + } + + fn generatedIteratorContent( + self: *BodyContext, + public_named: Type.NamedContent, + item_ty: Type.TypeId, + args: []const Type.TypeId, + digest: names.TypeDigest, + chain_depth: u32, + self_ty: Type.TypeId, + ) Allocator.Error!Type.Content { + const public_backing = public_named.backing orelse + Common.invariant("generated iterator requested a public Iter without backing"); + var def = public_named.def; + def.generated = digest; + def.iterator_representation = .minted; + def.iterator_depth = @intCast(chain_depth); + return .{ .named = .{ + .named_type = public_named.named_type, + .def = def, + .kind = public_named.kind, + .builtin_owner = public_named.builtin_owner, + .args = try self.builder.program.types.addSpan(args), + .backing = .{ + .ty = try self.generatedIteratorBackingType(public_backing.ty, self_ty, item_ty), + .use = public_backing.use, + }, + .declared_order = public_named.declared_order, + } }; + } + + fn forcedDynamicIteratorType( + self: *BodyContext, + public_iter_ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + const public_named = self.publicIteratorNamed(public_iter_ty); + const item_ty = self.iterItemType(public_iter_ty); + var hasher = std.crypto.hash.sha2.Sha256.init(.{}); + hasher.update("roc.forced_dynamic_iterator"); + self.updateTypeDigest(&hasher, item_ty); + const key = hasher.finalResult(); + if (self.builder.forced_dynamic_iter_types.get(key)) |cached| return cached; + + const Context = struct { + body: *BodyContext, + public_named: Type.NamedContent, + item_ty: Type.TypeId, + + fn fill(ctx: @This(), self_ty: Type.TypeId) Allocator.Error!Type.Content { + const public_backing = ctx.public_named.backing orelse + Common.invariant("forced dynamic iterator requested a public Iter without backing"); + var def = ctx.public_named.def; + def.generated = null; + def.iterator_representation = .forced_dynamic; + def.iterator_depth = max_minted_iterator_chain_depth; + const args = [_]Type.TypeId{ctx.item_ty}; + return .{ .named = .{ + .named_type = ctx.public_named.named_type, + .def = def, + .kind = ctx.public_named.kind, + .builtin_owner = ctx.public_named.builtin_owner, + .args = try ctx.body.builder.program.types.addSpan(&args), + .backing = .{ + .ty = try ctx.body.generatedIteratorBackingType(public_backing.ty, self_ty, ctx.item_ty), + .use = public_backing.use, + }, + .declared_order = ctx.public_named.declared_order, + } }; + } + }; + + const dynamic = try self.builder.program.types.addRecursive(Context{ + .body = self, + .public_named = public_named, + .item_ty = item_ty, + }, Context.fill); + try self.builder.forced_dynamic_iter_types.put(key, dynamic); + return dynamic; + } + + fn generatedIteratorBackingType( + self: *BodyContext, + public_backing_ty: Type.TypeId, + self_ty: Type.TypeId, + item_ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + const public_fields = switch (self.builder.shapeContent(public_backing_ty)) { + .record => |fields| self.builder.program.types.fieldSpan(fields), + else => Common.invariant("public Iter backing was not a record"), + }; + const fields = try self.allocator.alloc(Type.Field, public_fields.len); + defer self.allocator.free(fields); + for (0..public_fields.len) |index| { + const field = GuardedList.at(public_fields, index); + const field_text = self.builder.program.names.recordFieldLabelText(field.name); + fields[index] = .{ + .name = field.name, + .ty = if (Ident.textEql(field_text, "step")) + try self.generatedIteratorStepFunctionType(field.ty, self_ty, item_ty) + else + field.ty, + }; + } + return try self.builder.program.types.add(.{ .record = try self.builder.program.types.addRecordFields(&self.builder.program.names, fields) }); + } + + fn generatedIteratorStepFunctionType( + self: *BodyContext, + public_step_ty: Type.TypeId, + self_ty: Type.TypeId, + item_ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + const step = self.builder.functionShape(public_step_ty, "public Iter step field was not a function"); + return try self.builder.program.types.add(.{ .func = .{ + .args = step.args, + .ret = try self.generatedIteratorStepResultType(step.ret, self_ty, item_ty), + } }); + } + + fn generatedIteratorStepResultType( + self: *BodyContext, + public_step_ret_ty: Type.TypeId, + self_ty: Type.TypeId, + item_ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + const public_tags = switch (self.builder.shapeContent(public_step_ret_ty)) { + .tag_union => |tags| self.builder.program.types.tagSpan(tags), + else => Common.invariant("public Iter step result was not a tag union"), + }; + const tags = try self.allocator.alloc(Type.Tag, public_tags.len); + defer self.allocator.free(tags); + for (0..public_tags.len) |index| { + const tag = GuardedList.at(public_tags, index); + const payloads = self.builder.program.types.span(tag.payloads); + const generated_payloads = try self.allocator.alloc(Type.TypeId, payloads.len); + defer self.allocator.free(generated_payloads); + for (0..payloads.len) |payload_index| { + const payload = GuardedList.at(payloads, payload_index); + generated_payloads[payload_index] = try self.generatedIteratorStepPayloadType(tag.name, payload, self_ty, item_ty); + } + tags[index] = .{ + .name = tag.name, + .checked_name = tag.checked_name, + .payloads = try self.builder.program.types.addSpan(generated_payloads), + }; + } + return try self.builder.program.types.add(.{ .tag_union = try self.builder.program.types.addTagVariants(&self.builder.program.names, tags) }); + } + + fn generatedIteratorStepPayloadType( + self: *BodyContext, + tag_name: names.TagNameId, + payload_ty: Type.TypeId, + self_ty: Type.TypeId, + item_ty: Type.TypeId, + ) Allocator.Error!Type.TypeId { + const tag_text = self.builder.program.names.tagLabelText(tag_name); + if (!Ident.textEql(tag_text, "One") and !Ident.textEql(tag_text, "Skip")) return payload_ty; + + const public_fields = switch (self.builder.shapeContent(payload_ty)) { + .record => |fields| self.builder.program.types.fieldSpan(fields), + else => Common.invariant("public Iter step payload was not a record"), + }; + const fields = try self.allocator.alloc(Type.Field, public_fields.len); + defer self.allocator.free(fields); + for (0..public_fields.len) |index| { + const field = GuardedList.at(public_fields, index); + const field_text = self.builder.program.names.recordFieldLabelText(field.name); + fields[index] = .{ + .name = field.name, + .ty = if (Ident.textEql(field_text, "rest")) + self_ty + else if (Ident.textEql(field_text, "item")) + item_ty + else + field.ty, + }; + } + return try self.builder.program.types.add(.{ .record = try self.builder.program.types.addRecordFields(&self.builder.program.names, fields) }); + } + + fn lowerFieldNamesValueIter( + self: *BodyContext, + backing_fields: anytype, + fields_ty: Type.TypeId, + fields_local: DraftLocalId, + field_handle_ty: Type.TypeId, + iter_ty: Type.TypeId, + iter_backing_ty: Type.TypeId, + len_ty: Type.TypeId, + step_fn_ty: Type.TypeId, + step_ret_ty: Type.TypeId, + size_local: ?DraftLocalId, + arg_tys: []const Type.TypeId, + checked_source_ty: checked.CheckedTypeId, + source_expr_id: checked.CheckedExprId, + ) Allocator.Error!DraftExprId { + const fields_backing_ty = self.builder.namedBackingType(fields_ty) orelse + Common.invariant("generated FieldNames value expected a named backing type"); + const info = self.generatedFieldNamesBackingInfoFromBacking(fields_backing_ty) orelse + Common.invariant("generated FieldNames value expected a generated backing type"); + const item_fields = try GuardedList.dupe(self.allocator, Type.Field, info.item_fields); + defer self.allocator.free(item_fields); + if (GuardedList.borrowLen(backing_fields) != item_fields.len) Common.invariant("generated FieldNames iterator arity differed from item count"); + const backing_local = try self.addLocal(self.builder.symbols.fresh(), fields_backing_ty); + const items_expr = try self.addExpr(.{ + .ty = info.items_field.ty, + .data = .{ .field_access = .{ + .receiver = try self.localExpr(backing_local, fields_backing_ty), + .field = info.items_field.name, + } }, + }); + const items_local = try self.addLocal(self.builder.symbols.fresh(), info.items_field.ty); + const body = try self.lowerFieldNamesValueIterFromIndex( + item_fields, + info.items_field.ty, + items_local, 0, field_handle_ty, iter_ty, @@ -13829,18 +15326,22 @@ const BodyContext = struct { call_ctx.current_entry_root = self.current_entry_root; const source_fn_ty = self.directCallInstantiationSourceFnType(target, call.source_fn_ty_payload); - const mono_fn_ty = try call_ctx.instantiateCallTypeFromCallerAtType(source_fn_ty, self, checked_ret_ty, call.args, expected_ret_ty); - const source_fn_key = call_ctx.view.types.rootKey(source_fn_ty); - const callee = try self.fnTemplateForDirectCallWithMono(target, source_fn_ty, source_fn_key, mono_fn_ty); + var mono_fn_ty = try call_ctx.instantiateCallTypeFromCallerAtType(source_fn_ty, self, checked_ret_ty, call.args, expected_ret_ty); + if (try self.generatedIteratorDirectCallFunctionType(target, mono_fn_ty, call.args, expected_ret_ty)) |generated_fn_ty| { + mono_fn_ty = generated_fn_ty; + } const fn_data = self.builder.functionShape(mono_fn_ty, "checked direct call target had a non-function type"); try self.constrainTypeToMono(checked_ret_ty, fn_data.ret); if (expected_ret_ty) |expected| { - if (!self.sameType(expected, fn_data.ret)) { + if (!try self.sameTypeOrPublicOpaque(expected, fn_data.ret)) { Common.invariant("checked direct call result type differed from its expected Monotype type"); } } + if (try self.lowerGeneratedIteratorNextCall(target, call.args, fn_data, checked_ret_ty)) |lowered| return lowered; + const source_fn_key = call_ctx.view.types.rootKey(source_fn_ty); + const callee = try self.fnTemplateForDirectCallWithMono(target, source_fn_ty, source_fn_key, mono_fn_ty); return .{ - .ret_ty = try self.lowerTypeCell(checked_ret_ty), + .ret_ty = try self.callReturnTypeCell(checked_ret_ty, fn_data.ret), .data = .{ .call_proc = .{ .callee = draftProcCalleeFromAst(Ast.procCalleeForSlot(callee)), .args = try self.lowerExprSpanAtTypes(call.args, self.builder.program.types.span(fn_data.args)), @@ -13862,12 +15363,12 @@ const BodyContext = struct { const fn_data = self.builder.functionShape(fn_ty, "checked call function type was not a function"); try self.constrainTypeToMono(checked_ret_ty, fn_data.ret); if (expected_ret_ty) |expected| { - if (!self.sameType(expected, fn_data.ret)) { + if (!try self.sameTypeOrPublicOpaque(expected, fn_data.ret)) { Common.invariant("checked indirect call result type differed from its expected Monotype type"); } } return .{ - .ret_ty = try self.lowerTypeCell(checked_ret_ty), + .ret_ty = try self.callReturnTypeCell(checked_ret_ty, fn_data.ret), .data = .{ .call_value = .{ .callee = try self.lowerExprAtType(call.func, fn_ty), .args = try self.lowerExprSpanAtTypes(call.args, self.builder.program.types.span(fn_data.args)), @@ -13875,6 +15376,68 @@ const BodyContext = struct { }; } + fn lowerGeneratedIteratorNextCall( + self: *BodyContext, + target: checked.ResolvedValueId, + checked_args: []const checked.CheckedExprId, + fn_data: FunctionShape, + checked_ret_ty: checked.CheckedTypeId, + ) Allocator.Error!?LoweredCall { + const text = self.builtinProcedureTextForResolvedTarget(target) orelse return null; + if (!isBuiltinIterNextText(text)) return null; + const arg_tys = self.builder.program.types.span(fn_data.args); + if (checked_args.len != 1 or arg_tys.len != 1) Common.invariant("Iter.next reached Monotype with an unexpected arity"); + const iter_ty = GuardedList.at(arg_tys, 0); + if (!self.isGeneratedIteratorEvidenceType(iter_ty)) return null; + + const iterator = try self.lowerExprAtType(checked_args[0], iter_ty); + return .{ + .ret_ty = try self.callReturnTypeCell(checked_ret_ty, fn_data.ret), + .data = try self.lowerGeneratedIteratorNextData(iterator, iter_ty), + }; + } + + fn lowerGeneratedIteratorNextExpr( + self: *BodyContext, + iterator: DraftExprId, + iter_ty: Type.TypeId, + ) Allocator.Error!DraftExprId { + return try self.addExpr(.{ + .ty = try self.generatedIteratorStepReturnType(iter_ty), + .data = try self.lowerGeneratedIteratorNextData(iterator, iter_ty), + }); + } + + fn lowerGeneratedIteratorNextData( + self: *BodyContext, + iterator: DraftExprId, + iter_ty: Type.TypeId, + ) Allocator.Error!BodyExprData { + const backing_ty = self.builder.namedBackingType(iter_ty) orelse + Common.invariant("generated iterator next requested a type without backing"); + const step_name = try self.builder.program.names.internRecordFieldLabel("step"); + const step_ty = self.builder.recordFieldType(backing_ty, step_name); + const step = try self.addExpr(.{ .ty = step_ty, .data = .{ .field_access = .{ + .receiver = iterator, + .field = step_name, + } } }); + return .{ .call_value = .{ + .callee = step, + .args = .empty(), + } }; + } + + fn callReturnTypeCell( + self: *BodyContext, + checked_ret_ty: checked.CheckedTypeId, + ret_ty: Type.TypeId, + ) Allocator.Error!DraftTypeCell { + if (try self.builder.monoTypeHasGeneratedOpaqueEvidence(ret_ty)) { + return DraftTypeCell.fromSealed(ret_ty); + } + return try self.lowerTypeCell(checked_ret_ty); + } + fn indirectCalleeMonoType( self: *BodyContext, checked_func: checked.CheckedExprId, @@ -13895,7 +15458,7 @@ const BodyContext = struct { const field_ty = try self.lowerExprType(checked_func); if (expected_ret_ty) |expected| { const fn_data = self.builder.functionShape(field_ty, "checked field callee type was not a function"); - if (!self.sameType(expected, fn_data.ret)) break :blk null; + if (!try self.sameTypeOrPublicOpaque(expected, fn_data.ret)) break :blk null; } break :blk field_ty; }, @@ -13923,7 +15486,7 @@ const BodyContext = struct { const arg_tys = self.builder.program.types.span(fn_data.args); if (arg_tys.len != checked_args.len) Common.invariant("checked local callee arity differed from call arity"); if (expected_ret_ty) |expected| { - if (!self.sameType(expected, fn_data.ret)) return null; + if (!try self.sameTypeOrPublicOpaque(expected, fn_data.ret)) return null; } for (checked_args) |checked_arg| { if (try self.callArgumentMonoType(checked_arg, null)) |evidence_ty| { @@ -13997,6 +15560,7 @@ const BodyContext = struct { defer self.allocator.free(generated_arg_overrides); @memset(generated_arg_overrides, null); var saw_generated_opaque_evidence = false; + var generated_ret_override: ?Type.TypeId = null; for (function.args, checked_args, 0..) |formal_ty, checked_arg, index| { const arg_ty = caller.view.bodies.expr(checked_arg).ty; const formal_node = try self.instNode(formal_ty); @@ -14016,9 +15580,18 @@ const BodyContext = struct { try self.graph.unify(formal_node, try caller.instNode(arg_ty)); } } - try self.graph.unify(try self.instNode(function.ret), try caller.instNode(checked_ret_ty)); if (expected_ret_ty) |expected| { - try self.graph.unify(try self.instNode(function.ret), try self.graph.importMono(expected)); + if (self.isGeneratedSpecializationEvidenceType(expected)) { + saw_generated_opaque_evidence = true; + generated_ret_override = try self.graph.sealType(expected); + try self.graph.unify(try self.instNode(function.ret), try self.graph.importMono(try self.publicOpaqueUnificationType(generated_ret_override.?))); + try self.graph.unify(try caller.instNode(checked_ret_ty), try self.graph.importMono(try self.publicOpaqueUnificationType(generated_ret_override.?))); + } else { + try self.graph.unify(try self.instNode(function.ret), try caller.instNode(checked_ret_ty)); + try self.graph.unify(try self.instNode(function.ret), try self.graph.importMono(expected)); + } + } else { + try self.graph.unify(try self.instNode(function.ret), try caller.instNode(checked_ret_ty)); } try self.graph.drainDirty(); if (saw_generated_opaque_evidence) { @@ -14032,7 +15605,7 @@ const BodyContext = struct { } const generated_fn_ty = try self.builder.program.types.add(.{ .func = .{ .args = try self.builder.program.types.addSpan(args), - .ret = try self.graph.sealNode(try self.instNode(function.ret)), + .ret = generated_ret_override orelse try self.graph.sealNode(try self.instNode(function.ret)), } }); return generated_fn_ty; } @@ -14047,7 +15620,71 @@ const BodyContext = struct { operands: []const static_dispatch.StaticDispatchOperand, expected_ret_ty: ?Type.TypeId, ) Allocator.Error!Type.TypeId { - const fn_node = try self.instantiateDispatchPlanCallNodeFromCaller(source_fn_ty, caller, checked_ret_ty, operands, expected_ret_ty); + const function = self.checkedFunctionType(source_fn_ty); + if (function.args.len != operands.len) { + Common.invariant("checked dispatch plan arity differs from its function type"); + } + const fn_node = try self.instNode(source_fn_ty); + const generated_arg_overrides = try self.allocator.alloc(?Type.TypeId, function.args.len); + defer self.allocator.free(generated_arg_overrides); + @memset(generated_arg_overrides, null); + var saw_generated_opaque_evidence = false; + var generated_ret_override: ?Type.TypeId = null; + for (function.args, operands, 0..) |formal_ty, operand, index| { + const formal_node = try self.instNode(formal_ty); + switch (operand) { + .checked_expr => |checked_arg| { + const arg_ty = caller.view.bodies.expr(checked_arg).ty; + if (try caller.callArgumentMonoType(checked_arg, null)) |evidence_ty| { + if (self.isGeneratedSpecializationEvidenceType(evidence_ty)) { + const evidence_snapshot = try self.graph.sealType(evidence_ty); + saw_generated_opaque_evidence = true; + generated_arg_overrides[index] = evidence_snapshot; + try self.graph.unify(try self.graph.importMono(try self.publicOpaqueUnificationType(evidence_snapshot)), formal_node); + } else if (self.isGeneratedOpaqueEvidenceType(evidence_ty)) { + try self.graph.unify(try self.graph.importMono(try self.publicOpaqueUnificationType(evidence_ty)), formal_node); + } else { + try self.graph.unify(formal_node, try caller.instNode(arg_ty)); + try self.graph.unify(formal_node, try self.graph.importMono(evidence_ty)); + } + } else { + try self.graph.unify(formal_node, try caller.instNode(arg_ty)); + } + }, + .generated_interpolation_iter, + .generated_numeral, + .generated_quote, + => {}, + } + } + if (expected_ret_ty) |expected| { + if (self.isGeneratedSpecializationEvidenceType(expected)) { + saw_generated_opaque_evidence = true; + generated_ret_override = try self.graph.sealType(expected); + try self.graph.unify(try self.instNode(function.ret), try self.graph.importMono(try self.publicOpaqueUnificationType(generated_ret_override.?))); + try self.graph.unify(try caller.instNode(checked_ret_ty), try self.graph.importMono(try self.publicOpaqueUnificationType(generated_ret_override.?))); + } else { + try self.graph.unify(try self.instNode(function.ret), try caller.instNode(checked_ret_ty)); + try self.graph.unify(try self.instNode(function.ret), try self.graph.importMono(expected)); + } + } else { + try self.graph.unify(try self.instNode(function.ret), try caller.instNode(checked_ret_ty)); + } + try self.graph.drainDirty(); + if (saw_generated_opaque_evidence) { + const args = try self.allocator.alloc(Type.TypeId, function.args.len); + defer self.allocator.free(args); + for (function.args, generated_arg_overrides, 0..) |formal_ty, override, index| { + args[index] = if (override) |ty| + ty + else + try self.graph.sealNode(try self.instNode(formal_ty)); + } + return try self.builder.program.types.add(.{ .func = .{ + .args = try self.builder.program.types.addSpan(args), + .ret = generated_ret_override orelse try self.graph.sealNode(try self.instNode(function.ret)), + } }); + } return try self.activeTypeFromNode(fn_node); } @@ -14348,7 +15985,7 @@ const BodyContext = struct { if (try self.indirectCalleeMonoType(call.func, call.args, expected_ret_ty)) |fn_ty| { const ret_ty = self.functionReturnType(fn_ty); if (expected_ret_ty) |expected| { - if (!self.sameType(expected, ret_ty)) { + if (!try self.sameTypeOrPublicOpaque(expected, ret_ty)) { Common.invariant("checked indirect call result type differed from its expected Monotype type"); } try self.constrainTypeToMono(checked_ret_ty, expected); @@ -14379,7 +16016,10 @@ const BodyContext = struct { call_ctx.current_entry_root = self.current_entry_root; const source_fn_ty = self.directCallInstantiationSourceFnType(call.direct_target.?, call.source_fn_ty_payload); - const mono_fn_ty = try call_ctx.instantiateCallTypeFromCallerAtType(source_fn_ty, self, checked_ret_ty, call.args, expected_ret_ty); + var mono_fn_ty = try call_ctx.instantiateCallTypeFromCallerAtType(source_fn_ty, self, checked_ret_ty, call.args, expected_ret_ty); + if (try self.generatedIteratorDirectCallFunctionType(call.direct_target.?, mono_fn_ty, call.args, expected_ret_ty)) |generated_fn_ty| { + mono_fn_ty = generated_fn_ty; + } return call_ctx.functionReturnType(mono_fn_ty); } @@ -14602,6 +16242,8 @@ const BodyContext = struct { const live_ty = try self.lowerTypeView(binder_ty); const use_ty = if (self.sameType(ty, local_ty)) local_ty + else if (try self.generatedLocalUseType(local_ty, ty)) |generated_use_ty| + generated_use_ty else if (self.sameType(ty, live_ty)) live_ty else @@ -14726,7 +16368,15 @@ const BodyContext = struct { const store_view = self.builder.moduleForId(checked.constModuleId(const_use.const_ref)); const template = store_view.const_templates.get(const_use.const_ref); return switch (template.state) { - .stored_const => |stored| try self.restoreConstNodeAtType(store_view, self.view, stored.node, ty), + .stored_const => |stored| try self.restoredStaticDataCandidateNode( + store_view, + self.view, + stored.node, + ty, + const_use.const_ref, + requested_ty, + .disallow, + ), .reserved => Common.invariant("reserved checked const template reached Monotype"), .eval_template => |eval| try self.lowerConstEvalTemplateUse(store_view, eval, ty, null, null), }; @@ -14832,24 +16482,60 @@ const BodyContext = struct { type_view: ModuleView, node: checked.ConstNodeId, ty: Type.TypeId, + ) Allocator.Error!DraftExprId { + return try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, node, ty, null); + } + + fn restoreConstNodeAtTypeWithStaticRoot( + self: *BodyContext, + store_view: ModuleView, + type_view: ModuleView, + node: checked.ConstNodeId, + ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftExprId { const value = store_view.const_store.get(node); switch (value) { .fn_value => |fn_id| { - return try self.restoreConstFn(store_view, fn_id, ty); + return try self.restoreConstFn(store_view, fn_id, ty, static_data_const_locator); }, else => {}, } - const data = try self.restoreConstData(store_view, type_view, value, ty); + const data = try self.restoreConstData(store_view, type_view, value, ty, static_data_const_locator); return try self.addExpr(.{ .ty = ty, .data = data }); } + fn restoredStaticDataCandidateNode( + self: *BodyContext, + store_view: ModuleView, + type_view: ModuleView, + node: checked.ConstNodeId, + ty: Type.TypeId, + const_locator: checked.ConstLocator, + checked_type: checked.CheckedTypeId, + bare_fn: Builder.BareFnCandidate, + ) Allocator.Error!DraftExprId { + if (!moduleBytesEqual(checked.constModuleId(const_locator).bytes, store_view.key.bytes)) { + Common.invariant("static-data const context referenced a different ConstStore module"); + } + const runtime_expr = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, node, ty, const_locator); + if (self.builder.static_data_literals and self.builder.constNodeMayUseStaticDataCandidate(store_view, node, bare_fn)) { + const id = try self.builder.staticDataValue(const_locator, node, checked_type); + return try self.addExpr(.{ .ty = ty, .data = .{ .static_data_candidate = .{ + .static_data = id, + .runtime_expr = runtime_expr, + } } }); + } + return runtime_expr; + } + fn restoreConstData( self: *BodyContext, store_view: ModuleView, type_view: ModuleView, value: checked.ConstValue, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!BodyExprData { return switch (value) { .pending => Common.invariant("pending ConstStore node reached Monotype restore"), @@ -14865,21 +16551,21 @@ const BodyContext = struct { str.offset, str.len, ) }, - .list => |items| .{ .list = try self.restoreConstList(store_view, type_view, ty, items) }, + .list => |items| .{ .list = try self.restoreConstList(store_view, type_view, ty, items, static_data_const_locator) }, .box => |payload| blk: { - const child = try self.restoreConstNodeAtType(store_view, type_view, payload, self.constBoxPayloadType(ty)); + const child = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, payload, self.constBoxPayloadType(ty), static_data_const_locator); break :blk .{ .low_level = .{ .op = .box_box, .args = try self.addExprSpan(&.{child}), } }; }, - .tuple => |items| .{ .tuple = try self.restoreConstTuple(store_view, type_view, ty, items) }, - .record => |items| .{ .record = try self.restoreConstRecord(store_view, type_view, ty, items) }, + .tuple => |items| .{ .tuple = try self.restoreConstTuple(store_view, type_view, ty, items, static_data_const_locator) }, + .record => |items| .{ .record = try self.restoreConstRecord(store_view, type_view, ty, items, static_data_const_locator) }, .tag => |tag| .{ .tag = .{ .name = try self.builder.program.names.internTagLabel(tag.tag_name), - .payloads = try self.restoreConstTagPayloads(store_view, type_view, ty, tag), + .payloads = try self.restoreConstTagPayloads(store_view, type_view, ty, tag, static_data_const_locator), } }, - .nominal => |nominal| .{ .nominal = try self.restoreConstNodeAtType(store_view, type_view, nominal.backing, self.builder.namedBackingType(ty) orelse ty) }, + .nominal => |nominal| .{ .nominal = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, nominal.backing, self.builder.namedBackingType(ty) orelse ty, static_data_const_locator) }, .fn_value => Common.invariant("ConstStore function value must be restored as an expression"), }; } @@ -14890,12 +16576,13 @@ const BodyContext = struct { type_view: ModuleView, ty: Type.TypeId, items: []const checked.ConstNodeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftSpan(DraftExprId) { const elem_ty = self.constListElemType(ty); const lowered = try self.allocator.alloc(DraftExprId, items.len); defer self.allocator.free(lowered); for (items, 0..) |item, index| { - lowered[index] = try self.restoreConstNodeAtType(store_view, type_view, item, elem_ty); + lowered[index] = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, item, elem_ty, static_data_const_locator); } return try self.addExprSpan(lowered); } @@ -14906,6 +16593,7 @@ const BodyContext = struct { type_view: ModuleView, ty: Type.TypeId, items: []const checked.ConstNodeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftSpan(DraftExprId) { const item_span = self.builder.tupleItemSpan(ty); const item_count: usize = @intCast(item_span.len); @@ -14915,7 +16603,7 @@ const BodyContext = struct { for (items, 0..) |item, index| { const item_tys = self.builder.program.types.span(item_span); const item_ty = GuardedList.at(item_tys, index); - lowered[index] = try self.restoreConstNodeAtType(store_view, type_view, item, item_ty); + lowered[index] = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, item, item_ty, static_data_const_locator); } return try self.addExprSpan(lowered); } @@ -14926,6 +16614,7 @@ const BodyContext = struct { type_view: ModuleView, ty: Type.TypeId, items: []const checked.ConstNodeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftSpan(DraftFieldExpr) { const field_span = self.builder.recordFieldsSpan(ty); const field_count: usize = @intCast(field_span.len); @@ -14937,7 +16626,7 @@ const BodyContext = struct { const field = GuardedList.at(fields, index); lowered[index] = .{ .name = field.name, - .value = try self.restoreConstNodeAtType(store_view, type_view, item, field.ty), + .value = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, item, field.ty, static_data_const_locator), }; } return try self.addFieldExprSpan(lowered); @@ -14949,6 +16638,7 @@ const BodyContext = struct { type_view: ModuleView, ty: Type.TypeId, tag: anytype, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftSpan(DraftExprId) { const mono_tag_name = try self.builder.program.names.internTagLabel(tag.tag_name); const payload_span = self.builder.tagPayloadSpan(ty, mono_tag_name); @@ -14959,7 +16649,7 @@ const BodyContext = struct { for (tag.payloads, 0..) |payload, index| { const payload_tys = self.builder.program.types.span(payload_span); const payload_ty = GuardedList.at(payload_tys, index); - lowered[index] = try self.restoreConstNodeAtType(store_view, type_view, payload, payload_ty); + lowered[index] = try self.restoreConstNodeAtTypeWithStaticRoot(store_view, type_view, payload, payload_ty, static_data_const_locator); } return try self.addExprSpan(lowered); } @@ -14987,19 +16677,20 @@ const BodyContext = struct { store_view: ModuleView, fn_id: checked.ConstFnId, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftExprId { const raw = @intFromEnum(fn_id); if (raw >= store_view.const_store.fns.items.len) Common.invariant("ConstStore function id is out of range"); const fn_value = store_view.const_store.getFn(@enumFromInt(raw)); if (fn_value.fn_def == .parser_runtime) { - return try self.restoreConstParserRuntimeFn(store_view, fn_value, ty); + return try self.restoreConstParserRuntimeFn(store_view, fn_value, ty, static_data_const_locator); } if (fn_value.fn_def == .encoder_for_runtime) { - return try self.restoreConstEncoderForRuntimeFn(store_view, fn_value, ty); + return try self.restoreConstEncoderForRuntimeFn(store_view, fn_value, ty, static_data_const_locator); } const template = try self.builder.restoredConstFnTemplateToMono(store_view, fn_id, fn_value, ty); if (fn_value.captures.len != 0) { - return try self.restoreCapturingConstFn(store_view, fn_id, fn_value, template, ty); + return try self.restoreCapturingConstFn(store_view, fn_id, fn_value, template, ty, static_data_const_locator); } const mono_fn_id = try self.restoreConstFnTemplate(fn_value, template); return try self.addExpr(.{ @@ -15032,6 +16723,7 @@ const BodyContext = struct { fn_value: check.ConstStore.ConstFn, template: Ast.FnTemplate, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftExprId { const fn_view = self.builder.moduleForConstFnDef(fn_value.fn_def); var fn_ctx = try BodyContext.init(self.allocator, self.builder, fn_view, ownerTemplateForConstFnDef(fn_value.fn_def), self.graph, self.draft); @@ -15089,7 +16781,10 @@ const BodyContext = struct { initialized += 1; } for (fn_value.captures, 0..) |capture, index| { - captures[index].value = try fn_ctx.restoreConstNodeAtType(store_view, fn_view, capture.value, captures[index].ty); + captures[index].value = if (static_data_const_locator) |const_locator| + try fn_ctx.restoredStaticDataCandidateNode(store_view, fn_view, capture.value, captures[index].ty, const_locator, checkedBinderType(fn_view, constCaptureBinder(capture.id)), .allow) + else + try fn_ctx.restoreConstNodeAtType(store_view, fn_view, capture.value, captures[index].ty); } var capture_template = template; @@ -15147,6 +16842,7 @@ const BodyContext = struct { store_view: ModuleView, fn_value: check.ConstStore.ConstFn, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftExprId { const runtime = switch (fn_value.fn_def) { .parser_runtime => |runtime| runtime, @@ -15188,7 +16884,10 @@ const BodyContext = struct { fn_ctx.setLocalCaptureId(local, parserEncodingCaptureId()); encoding_let = .{ .local = local, - .value = try fn_ctx.restoreConstNodeAtType(store_view, fn_view, node, arg_tys[0]), + .value = if (static_data_const_locator) |const_locator| + try fn_ctx.restoreConstNodeAtTypeWithStaticRoot(store_view, fn_view, node, arg_tys[0], const_locator) + else + try fn_ctx.restoreConstNodeAtType(store_view, fn_view, node, arg_tys[0]), }; break :blk try fn_ctx.localExpr(local, arg_tys[0]); } else try fn_ctx.lowerDispatchOperandAtType(plan_args[0], arg_tys[0]); @@ -15240,6 +16939,7 @@ const BodyContext = struct { store_view: ModuleView, fn_value: check.ConstStore.ConstFn, ty: Type.TypeId, + static_data_const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftExprId { const runtime = switch (fn_value.fn_def) { .encoder_for_runtime => |runtime| runtime, @@ -15283,7 +16983,10 @@ const BodyContext = struct { fn_ctx.setLocalCaptureId(local, encoderForEncodingCaptureId()); encoding_let = .{ .local = local, - .value = try fn_ctx.restoreConstNodeAtType(store_view, fn_view, node, arg_tys[0]), + .value = if (static_data_const_locator) |const_locator| + try fn_ctx.restoreConstNodeAtTypeWithStaticRoot(store_view, fn_view, node, arg_tys[0], const_locator) + else + try fn_ctx.restoreConstNodeAtType(store_view, fn_view, node, arg_tys[0]), }; break :blk try fn_ctx.localExpr(local, arg_tys[0]); } else try fn_ctx.lowerDispatchOperandAtType(plan_args[0], arg_tys[0]); @@ -15681,7 +17384,7 @@ const BodyContext = struct { return switch (self.builder.program.types.get(ty)) { .named => |named| blk: { const args = self.builder.program.types.span(named.args); - if (args.len != 1) Common.invariant("Iter nominal did not have one type argument"); + if (GuardedList.borrowLen(args) == 0) Common.invariant("Iter nominal did not have an item type argument"); break :blk GuardedList.at(args, 0); }, else => Common.invariant("generated interpolation iterator expected named Iter type"), @@ -15707,7 +17410,8 @@ const BodyContext = struct { if (try self.lowerParseIntrinsicCallExpr(checked_expr, expr.ty, call, ty)) |lowered| return lowered; try self.constrainKnownType(expr.ty, ty); const lowered = try self.lowerCallAtType(expr.ty, call, ty); - if (!self.sameType(ty, try self.activeTypeFromCell(lowered.ret_ty))) { + const actual_ty = try self.activeTypeFromCell(lowered.ret_ty); + if (!try self.sameTypeOrPublicOpaque(ty, actual_ty)) { Common.invariant("checked call expression lowered at a type different from its context type"); } return try self.addExpr(.{ @@ -15751,12 +17455,201 @@ const BodyContext = struct { } try self.constrainKnownType(expr.ty, ty); const lowered = try self.lowerExprWithType(checked_expr, ty); - if (!self.sameType(ty, try self.exprType(lowered))) { + if (!try self.sameTypeOrPublicOpaque(ty, try self.exprType(lowered))) { Common.invariant("checked expression lowered at a type different from its call operand type"); } return lowered; } + fn sameTypeOrPublicOpaque(self: *BodyContext, expected: Type.TypeId, actual: Type.TypeId) Allocator.Error!bool { + const stable_expected = if (self.isGeneratedIteratorEvidenceType(expected)) + try self.stableGeneratedIteratorEvidenceType(expected) + else + expected; + const stable_actual = if (self.isGeneratedIteratorEvidenceType(actual)) + try self.stableGeneratedIteratorEvidenceType(actual) + else + actual; + if (self.sameType(stable_expected, stable_actual)) return true; + const public_expected = try self.publicOpaqueUnificationType(stable_expected); + const public_actual = try self.publicOpaqueUnificationType(stable_actual); + return self.samePublicOpaqueType(public_expected, public_actual); + } + + fn generatedLocalUseType( + self: *BodyContext, + local_ty: Type.TypeId, + expected_ty: Type.TypeId, + ) Allocator.Error!?Type.TypeId { + if (!self.isGeneratedSpecializationEvidenceType(local_ty)) return null; + if (self.isGeneratedSpecializationEvidenceType(expected_ty)) { + if (self.isGeneratedIteratorEvidenceType(local_ty) and self.isGeneratedIteratorEvidenceType(expected_ty)) { + const stable_local = try self.stableGeneratedIteratorEvidenceType(local_ty); + const stable_expected = try self.stableGeneratedIteratorEvidenceType(expected_ty); + if (self.sameType(stable_expected, stable_local)) return local_ty; + } + return null; + } + + const public_local = try self.publicOpaqueUnificationType(local_ty); + const public_expected = try self.publicOpaqueUnificationType(expected_ty); + if (self.samePublicOpaqueType(public_expected, public_local)) return local_ty; + return null; + } + + fn samePublicOpaqueType(self: *BodyContext, expected: Type.TypeId, actual: Type.TypeId) bool { + var visiting = std.AutoHashMap(TypePair, void).init(self.allocator); + defer visiting.deinit(); + return self.samePublicOpaqueTypeInner(expected, actual, &visiting); + } + + fn samePublicOpaqueTypeInner( + self: *BodyContext, + expected: Type.TypeId, + actual: Type.TypeId, + visiting: *std.AutoHashMap(TypePair, void), + ) bool { + if (expected == actual) return true; + if (monoAliasBacking(&self.builder.program.types, expected)) |backing| { + if (self.samePublicOpaqueTypeInner(backing, actual, visiting)) return true; + } + if (monoAliasBacking(&self.builder.program.types, actual)) |backing| { + if (self.samePublicOpaqueTypeInner(expected, backing, visiting)) return true; + } + + const pair = TypePair{ .expected = expected, .actual = actual }; + if (visiting.contains(pair)) return true; + visiting.put(pair, {}) catch Common.invariant("public opaque type equality could not record a recursive type pair"); + defer _ = visiting.remove(pair); + + const expected_content = self.builder.program.types.get(expected); + const actual_content = self.builder.program.types.get(actual); + return switch (expected_content) { + .primitive => |primitive| switch (actual_content) { + .primitive => |actual_primitive| primitive == actual_primitive, + else => false, + }, + .named => |named| switch (actual_content) { + .named => |actual_named| self.samePublicOpaqueNamedType(named, actual_named, visiting), + else => false, + }, + .record => |fields| switch (actual_content) { + .record => |actual_fields| self.samePublicOpaqueRecordFieldNames(fields, actual_fields, visiting), + else => false, + }, + .tuple => |items| switch (actual_content) { + .tuple => |actual_items| self.samePublicOpaqueTypeSpans(items, actual_items, visiting), + else => false, + }, + .tag_union => |tags| switch (actual_content) { + .tag_union => |actual_tags| self.samePublicOpaqueTags(tags, actual_tags, visiting), + else => false, + }, + .list => |elem| switch (actual_content) { + .list => |actual_elem| self.samePublicOpaqueTypeInner(elem, actual_elem, visiting), + else => false, + }, + .box => |elem| switch (actual_content) { + .box => |actual_elem| self.samePublicOpaqueTypeInner(elem, actual_elem, visiting), + else => false, + }, + .func => |function| switch (actual_content) { + .func => |actual_function| self.samePublicOpaqueTypeSpans(function.args, actual_function.args, visiting) and + self.samePublicOpaqueTypeInner(function.ret, actual_function.ret, visiting), + else => false, + }, + .erased => |erased| switch (actual_content) { + .erased => |actual_erased| std.mem.eql(u8, erased.bytes[0..], actual_erased.bytes[0..]), + else => false, + }, + .zst => switch (actual_content) { + .zst => true, + else => false, + }, + }; + } + + fn samePublicOpaqueNamedType( + self: *BodyContext, + expected: anytype, + actual: anytype, + visiting: *std.AutoHashMap(TypePair, void), + ) bool { + if (expected.def.module != actual.def.module) return false; + if (expected.def.source_decl != actual.def.source_decl) return false; + if (expected.def.source_decl == null and expected.def.type_name != actual.def.type_name) return false; + if (expected.kind != actual.kind) return false; + if (!publicOpaqueBuiltinOwnersCompatible(expected.builtin_owner, actual.builtin_owner)) return false; + if (!self.samePublicOpaqueTypeSpans(expected.args, actual.args, visiting)) return false; + if (expected.kind == .@"opaque") return true; + if (!self.sameNamedBacking(expected.backing, actual.backing, visiting)) return false; + return self.sameDeclaredOrder(expected.declared_order, actual.declared_order, visiting); + } + + fn samePublicOpaqueTypeSpans( + self: *BodyContext, + expected: Type.Span, + actual: Type.Span, + visiting: *std.AutoHashMap(TypePair, void), + ) bool { + const expected_items = self.builder.program.types.span(expected); + const actual_items = self.builder.program.types.span(actual); + if (expected_items.len != actual_items.len) return false; + for (0..expected_items.len) |index| { + const expected_item = GuardedList.at(expected_items, index); + const actual_item = GuardedList.at(actual_items, index); + if (!self.samePublicOpaqueTypeInner(expected_item, actual_item, visiting)) return false; + } + return true; + } + + fn samePublicOpaqueRecordFieldNames( + self: *BodyContext, + expected: Type.Span, + actual: Type.Span, + visiting: *std.AutoHashMap(TypePair, void), + ) bool { + const expected_fields = self.builder.program.types.fieldSpan(expected); + const actual_fields = self.builder.program.types.fieldSpan(actual); + if (expected_fields.len != actual_fields.len) return false; + for (0..expected_fields.len) |index| { + const expected_field = GuardedList.at(expected_fields, index); + const actual_field = GuardedList.at(actual_fields, index); + if (expected_field.name != actual_field.name) return false; + if (!self.samePublicOpaqueTypeInner(expected_field.ty, actual_field.ty, visiting)) return false; + } + return true; + } + + fn samePublicOpaqueTags( + self: *BodyContext, + expected: Type.Span, + actual: Type.Span, + visiting: *std.AutoHashMap(TypePair, void), + ) bool { + const expected_tags = self.builder.program.types.tagSpan(expected); + const actual_tags = self.builder.program.types.tagSpan(actual); + if (expected_tags.len != actual_tags.len) return false; + for (0..expected_tags.len) |index| { + const expected_tag = GuardedList.at(expected_tags, index); + const actual_tag = GuardedList.at(actual_tags, index); + if (expected_tag.name != actual_tag.name or expected_tag.checked_name != actual_tag.checked_name) return false; + if (!self.samePublicOpaqueTypeSpans(expected_tag.payloads, actual_tag.payloads, visiting)) return false; + } + return true; + } + + fn publicOpaqueBuiltinOwnersCompatible(left: ?static_dispatch.BuiltinOwner, right: ?static_dispatch.BuiltinOwner) bool { + if (left == right) return true; + if (left) |left_owner| { + if (right == null and static_dispatch.isIteratorOwner(left_owner)) return true; + } + if (right) |right_owner| { + if (left == null and static_dispatch.isIteratorOwner(right_owner)) return true; + } + return false; + } + fn sameType(self: *BodyContext, expected: Type.TypeId, actual: Type.TypeId) bool { var visiting = std.AutoHashMap(TypePair, void).init(self.allocator); defer visiting.deinit(); @@ -15856,6 +17749,9 @@ const BodyContext = struct { if (expected.def.module != actual.def.module) return false; if (expected.def.source_decl != actual.def.source_decl) return false; if (expected.def.source_decl == null and expected.def.type_name != actual.def.type_name) return false; + if (!optionalDigestEql(expected.def.generated, actual.def.generated)) return false; + if (expected.def.iterator_representation != actual.def.iterator_representation) return false; + if (expected.def.iterator_depth != actual.def.iterator_depth) return false; if (expected.kind != actual.kind) return false; if (expected.builtin_owner != actual.builtin_owner) return false; if (!self.sameTypeSpans(expected.args, actual.args, visiting)) return false; @@ -16228,10 +18124,11 @@ const BodyContext = struct { .encoder => try self.lowerStructuralEncoderFor(plan, callable_mono_ty, plan_ret_ty, self, pre_lowered), }; } - const target_mono_ty = try self.methodTargetMonoTypeFromPlan(resolved, &call_ctx, plan.callable_ty, expected_ret_ty); + const target_mono_ty = (try self.generatedIteratorMethodTargetFunctionType(resolved, callable_mono_ty, plan_args, expected_ret_ty)) orelse + try self.methodTargetMonoTypeFromPlan(resolved, &call_ctx, plan.callable_ty, expected_ret_ty); try call_ctx.constrainTypeToMono(plan.callable_ty, target_mono_ty); const refreshed_target_mono_ty = try self.activeTypeFromType(target_mono_ty); - if (!self.sameType(callable_mono_ty, refreshed_target_mono_ty)) { + if (!try self.sameTypeOrPublicOpaque(callable_mono_ty, refreshed_target_mono_ty)) { Common.invariant("checked dispatch target callable type differed from dispatch plan callable type"); } const fn_data = self.builder.functionShape(refreshed_target_mono_ty, "checked dispatch target had a non-function type"); @@ -16240,7 +18137,7 @@ const BodyContext = struct { const ret_ty = try self.activeTypeFromType(fn_data.ret); if (expected_ret_ty) |expected| { const expected_ty = try self.activeTypeFromType(expected); - if (!self.sameType(expected_ty, ret_ty)) Common.invariant("checked dispatch expression lowered at a type different from its call operand type"); + if (!try self.sameTypeOrPublicOpaque(expected_ty, ret_ty)) Common.invariant("checked dispatch expression lowered at a type different from its call operand type"); } const call_expr = try self.addExpr(.{ .ty = ret_ty, @@ -16872,9 +18769,10 @@ const BodyContext = struct { try self.constrainTypeToMono(checked_ret_ty, plan_ret_ty); return plan_ret_ty; } - const target_mono_ty = try self.methodTargetMonoTypeFromPlan(resolved, &call_ctx, plan.callable_ty, null); + const target_mono_ty = (try self.generatedIteratorMethodTargetFunctionType(resolved, callable_mono_ty, plan_args, expected_ret_ty)) orelse + try self.methodTargetMonoTypeFromPlan(resolved, &call_ctx, plan.callable_ty, expected_ret_ty); try call_ctx.constrainTypeToMono(plan.callable_ty, target_mono_ty); - if (!self.sameType(callable_mono_ty, target_mono_ty)) { + if (!try self.sameTypeOrPublicOpaque(callable_mono_ty, target_mono_ty)) { Common.invariant("checked dispatch target callable type differed from dispatch plan callable type"); } const ret_ty = self.functionReturnType(target_mono_ty); @@ -17153,6 +19051,50 @@ const BodyContext = struct { return try target_ctx.instantiateTargetFromPlan(lookup.target.callable_ty, plan_ctx, plan_callable_ty, expected_ret_ty); } + fn generatedIteratorMethodTargetFunctionType( + self: *BodyContext, + lookup: MethodLookup, + mono_fn_ty: Type.TypeId, + operands: []const static_dispatch.StaticDispatchOperand, + expected_ret_ty: ?Type.TypeId, + ) Allocator.Error!?Type.TypeId { + const text = self.builtinProcedureTextForMethodLookup(lookup) orelse return null; + const checked_args = (try self.checkedExprDispatchOperands(operands)) orelse return null; + defer self.allocator.free(checked_args); + return try self.generatedIteratorBuiltinFunctionType(text, mono_fn_ty, checked_args, expected_ret_ty); + } + + fn checkedExprDispatchOperands( + self: *BodyContext, + operands: []const static_dispatch.StaticDispatchOperand, + ) Allocator.Error!?[]checked.CheckedExprId { + const checked_args = try self.allocator.alloc(checked.CheckedExprId, operands.len); + errdefer self.allocator.free(checked_args); + for (operands, 0..) |operand, index| { + checked_args[index] = switch (operand) { + .checked_expr => |expr| expr, + .generated_interpolation_iter, + .generated_numeral, + .generated_quote, + => { + self.allocator.free(checked_args); + return null; + }, + }; + } + return checked_args; + } + + fn builtinProcedureTextForMethodLookup(_: *BodyContext, lookup: MethodLookup) ?[]const u8 { + return switch (lookup.target.kind) { + .procedure => |procedure| builtinProcedureTextForTemplate(lookup.view, procedure.template), + .local_proc, + .generated_structural_parser, + .generated_structural_encoder, + => null, + }; + } + fn methodTargetMonoTypePreservingSourceArgsAndRet( self: *BodyContext, lookup: MethodLookup, @@ -22543,7 +24485,10 @@ const BodyContext = struct { try self.constrainTypeToMono(step.skip_rest.ty, iterator_ty); const item_ty = try self.lowerTypeView(step.one_item.ty); try self.constrainTypeToMono(plan.item_ty, item_ty); - const step_expected_ty = try self.lowerTypeView(plan.step_ty); + const step_expected_ty = if (self.isGeneratedIteratorEvidenceType(iterator_ty)) + try self.generatedIteratorStepReturnType(iterator_ty) + else + try self.lowerTypeView(plan.step_ty); const iterator_local = try self.addLocal(self.builder.symbols.fresh(), iterator_ty); const iterator_param = BodyTypedLocal{ .local = iterator_local, .ty = iterator_ty }; @@ -22652,6 +24597,10 @@ const BodyContext = struct { const plan_args = plan.argsSlice(self.view.static_dispatch_plans); if (plan.dispatcher_arg_index >= plan_args.len) Common.invariant("iterator dispatch plan dispatcher argument index was outside the argument span"); + if (try self.lowerGeneratedIteratorDispatch(plan, plan_args, loop_iterator, expected_ret_ty)) |generated| { + return generated; + } + var call_ctx = try BodyContext.init(self.allocator, self.builder, self.view, self.owner_template, self.graph, self.draft); call_ctx.evidence = self.evidence; defer call_ctx.deinit(); @@ -22736,6 +24685,39 @@ const BodyContext = struct { }); } + fn lowerGeneratedIteratorDispatch( + self: *BodyContext, + plan: static_dispatch.IteratorDispatchCall, + plan_args: []const static_dispatch.IteratorDispatchOperand, + loop_iterator: ?BodyTypedLocal, + expected_ret_ty: ?Type.TypeId, + ) Allocator.Error!?DraftExprId { + const method_text = self.view.names.methodNameText(plan.method); + if (!Ident.textEql(method_text, "iter") and !Ident.textEql(method_text, "next")) return null; + + const dispatcher_ty = (try self.iteratorOperandMonoType(plan_args[plan.dispatcher_arg_index], loop_iterator, null)) orelse + Common.invariant("iterator dispatch plan dispatcher operand did not have a Monotype"); + if (!self.isGeneratedIteratorEvidenceType(dispatcher_ty)) return null; + + if (Ident.textEql(method_text, "iter")) { + if (expected_ret_ty) |expected| { + if (!try self.sameTypeOrPublicOpaque(expected, dispatcher_ty)) { + Common.invariant("generated iterator .iter dispatch result differed from expected type"); + } + } + return try self.lowerIteratorOperandAtType(plan_args[plan.dispatcher_arg_index], loop_iterator, dispatcher_ty); + } + + const step_ret_ty = try self.generatedIteratorStepReturnType(dispatcher_ty); + if (expected_ret_ty) |expected| { + if (!try self.sameTypeOrPublicOpaque(expected, step_ret_ty)) { + Common.invariant("generated iterator .next dispatch result differed from expected type"); + } + } + const iterator = try self.lowerIteratorOperandAtType(plan_args[plan.dispatcher_arg_index], loop_iterator, dispatcher_ty); + return try self.lowerGeneratedIteratorNextExpr(iterator, dispatcher_ty); + } + fn instantiateIteratorPlanCallTypeFromCaller( self: *BodyContext, source_fn_ty: checked.CheckedTypeId, @@ -23599,7 +25581,7 @@ const BodyContext = struct { } fn lowerPatternAtType(self: *BodyContext, pattern_id: checked.CheckedPatternId, ty: Type.TypeId) Allocator.Error!DraftPatId { - return try self.lowerPatternAtTypeCell(pattern_id, try self.draftTypeCell(ty), ty); + return try self.lowerPatternAtTypeCell(pattern_id, try self.draftTypeCellPreservingGenerated(ty), ty); } fn lowerPatternAtTypeCell( @@ -23611,7 +25593,13 @@ const BodyContext = struct { const pattern = self.view.bodies.pattern(pattern_id); switch (pattern.data) { .assign => {}, - else => try self.constrainTypeToCell(pattern.ty, ty_cell), + else => { + if (try self.builder.monoTypeHasGeneratedOpaqueEvidence(ty)) { + try self.constrainTypeToMono(pattern.ty, try self.publicOpaqueUnificationType(ty)); + } else { + try self.constrainTypeToCell(pattern.ty, ty_cell); + } + }, } const data: BodyPatData = switch (pattern.data) { .pending, @@ -23637,7 +25625,7 @@ const BodyContext = struct { const backing_ty = self.builder.namedBackingType(ty) orelse ty; break :blk .{ .nominal = try self.lowerPatternAtTypeCell( nominal.backing_pattern, - try self.draftTypeCell(backing_ty), + try self.draftTypeCellPreservingGenerated(backing_ty), backing_ty, ) }; }, @@ -24904,7 +26892,16 @@ fn verifyMonotypeCallTargets(program: *const Ast.Program) void { fn sameTypeDef(left: Type.TypeDef, right: Type.TypeDef) bool { return left.module == right.module and left.type_name == right.type_name and - left.source_decl == right.source_decl; + left.source_decl == right.source_decl and + optionalDigestEql(left.generated, right.generated) and + left.iterator_representation == right.iterator_representation and + left.iterator_depth == right.iterator_depth; +} + +fn optionalDigestEql(left: ?names.TypeDigest, right: ?names.TypeDigest) bool { + if (left == null and right == null) return true; + if (left == null or right == null) return false; + return std.mem.eql(u8, left.?.bytes[0..], right.?.bytes[0..]); } const CheckedTypeAddress = struct { diff --git a/src/postcheck/monotype/serialize.zig b/src/postcheck/monotype/serialize.zig index aec0b056791..48f11ee7c03 100644 --- a/src/postcheck/monotype/serialize.zig +++ b/src/postcheck/monotype/serialize.zig @@ -24,9 +24,11 @@ pub const MAGIC: [8]u8 = .{ 'R', 'O', 'C', 'S', 'P', 'E', 'C', 0 }; /// Serialization format version for specialization cache files. /// Version 3: `SpecRecord` carries an immutable requested-type identity plus /// separate request/solved type views. -pub const FORMAT_VERSION: u32 = 3; +/// Version 4: Type definitions carry generated iterator backing evidence. +/// Version 5: Monotype programs carry restored static-data candidate records. +pub const FORMAT_VERSION: u32 = 5; -const SECTION_COUNT = 39; +const SECTION_COUNT = 40; /// Required byte alignment for every section payload. This covers all typed /// Monotype cache sections so mapping can produce process slices directly. pub const SECTION_ALIGNMENT: u64 = 16; @@ -71,6 +73,7 @@ pub const SectionId = enum(u8) { roots, layout_requests, runtime_schema_requests, + static_data_values, comptime_sites, source_files, expr_locs, @@ -207,6 +210,7 @@ pub const SpecializationCacheHeader = extern struct { roots: FileSlice = .{}, layout_requests: FileSlice = .{}, runtime_schema_requests: FileSlice = .{}, + static_data_values: FileSlice = .{}, /// Packed debug/source sections. These are byte payloads because the live /// builder representation still uses process pointers for text slices and /// branch-region lists. @@ -269,6 +273,7 @@ pub const MappedView = struct { .roots = try self.sectionTyped(Ast.Root, header.roots), .layout_requests = try self.sectionTyped(Ast.LayoutRequest, header.layout_requests), .runtime_schema_requests = try self.sectionTyped(Ast.RuntimeSchemaRequest, header.runtime_schema_requests), + .static_data_values = try self.sectionTyped(Ast.StaticDataValue, header.static_data_values), .comptime_sites = try self.sectionBytes(header.comptime_sites), .source_files = try self.sectionBytes(header.source_files), .expr_locs = try self.sectionTyped(Base.SourceLoc, header.expr_locs), @@ -314,6 +319,7 @@ pub const MappedSections = struct { roots: []const Ast.Root, layout_requests: []const Ast.LayoutRequest, runtime_schema_requests: []const Ast.RuntimeSchemaRequest, + static_data_values: []const Ast.StaticDataValue, comptime_sites: []const u8, source_files: []const u8, expr_locs: []const Base.SourceLoc, @@ -361,6 +367,7 @@ pub const MappedProgramView = struct { roots: []const Ast.Root, layout_requests: []const Ast.LayoutRequest, runtime_schema_requests: []const Ast.RuntimeSchemaRequest, + static_data_values: []const Ast.StaticDataValue, expr_locs: []const Base.SourceLoc, expr_regions: []const Base.Region, stmt_locs: []const Base.SourceLoc, @@ -519,6 +526,8 @@ pub const MappedProgramView = struct { .crash, .comptime_exhaustiveness_failed, => true, + .static_data_candidate => |candidate| self.staticDataRefInBounds(candidate.static_data) and + self.exprRefInBounds(candidate.runtime_expr), .list, .tuple => |span| self.exprIdSpanInBounds(span), .record => |span| self.fieldExprSpanInBounds(span), .tag => |tag| self.exprIdSpanInBounds(tag.payloads), @@ -628,6 +637,10 @@ pub const MappedProgramView = struct { return @intFromEnum(expr) < self.exprs.len; } + fn staticDataRefInBounds(self: MappedProgramView, id: Common.StaticDataId) bool { + return @intFromEnum(id) < self.static_data_values.len; + } + fn patRefInBounds(self: MappedProgramView, pat: Ast.PatId) bool { return @intFromEnum(pat) < self.pats.len; } @@ -823,6 +836,7 @@ pub fn mappedProgramView(view: MappedView) CacheError!MappedProgramView { .roots = sections_.roots, .layout_requests = sections_.layout_requests, .runtime_schema_requests = sections_.runtime_schema_requests, + .static_data_values = sections_.static_data_values, .expr_locs = sections_.expr_locs, .expr_regions = sections_.expr_regions, .stmt_locs = sections_.stmt_locs, @@ -982,7 +996,7 @@ pub fn computeValidityId(inputs: ValidityInputs) [32]u8 { writeHashBytes(&hasher, "static-data-requests"); writeHashU32(&hasher, @intCast(inputs.roots.static_data_requests.len)); for (inputs.roots.static_data_requests) |request| { - writeProvidedDataExport(&hasher, request.data); + writeStaticDataRequest(&hasher, request); } writeHashBytes(&hasher, "spec-records"); @@ -1047,6 +1061,7 @@ pub fn computeCompilerLayoutHash() [32]u8 { writeLayout(&hasher, Ast.Root); writeLayout(&hasher, Ast.LayoutRequest); writeLayout(&hasher, Ast.RuntimeSchemaRequest); + writeLayout(&hasher, Ast.StaticDataValue); writeLayout(&hasher, Base.SourceLoc); writeLayout(&hasher, Base.Region); @@ -1100,6 +1115,7 @@ fn sections(header: *const SpecializationCacheHeader) [SECTION_COUNT]FileSlice { header.roots, header.layout_requests, header.runtime_schema_requests, + header.static_data_values, header.comptime_sites, header.source_files, header.expr_locs, @@ -1143,6 +1159,7 @@ const section_order = [_]SectionId{ .roots, .layout_requests, .runtime_schema_requests, + .static_data_values, .comptime_sites, .source_files, .expr_locs, @@ -1186,14 +1203,15 @@ fn sectionIndex(id: SectionId) usize { .roots => 28, .layout_requests => 29, .runtime_schema_requests => 30, - .comptime_sites => 31, - .source_files => 32, - .expr_locs => 33, - .expr_regions => 34, - .stmt_locs => 35, - .stmt_regions => 36, - .local_names => 37, - .debug_names => 38, + .static_data_values => 31, + .comptime_sites => 32, + .source_files => 33, + .expr_locs => 34, + .expr_regions => 35, + .stmt_locs => 36, + .stmt_regions => 37, + .local_names => 38, + .debug_names => 39, }; } @@ -1253,6 +1271,7 @@ fn setSection(header: *SpecializationCacheHeader, id: SectionId, slice: FileSlic .roots => header.roots = slice, .layout_requests => header.layout_requests = slice, .runtime_schema_requests => header.runtime_schema_requests = slice, + .static_data_values => header.static_data_values = slice, .comptime_sites => header.comptime_sites = slice, .source_files => header.source_files = slice, .expr_locs => header.expr_locs = slice, @@ -1303,14 +1322,19 @@ fn writeRootSource(hasher: *std.crypto.hash.sha2.Sha256, source: checked.RootSou } } -fn writeProvidedDataExport(hasher: *std.crypto.hash.sha2.Sha256, data: checked.ProvidedDataExport) void { - writeHashU32(hasher, @intFromEnum(data.source_name)); - writeHashU32(hasher, @intFromEnum(data.ffi_symbol)); - writeHashU32(hasher, @intFromEnum(data.def)); - writeHashU32(hasher, @intFromEnum(data.pattern)); - writeCheckedTypeId(hasher, data.checked_type); - writeHashBytes32(hasher, data.source_scheme.bytes); - writeConstData(hasher, data.const_ref); +fn writeStaticDataRequest(hasher: *std.crypto.hash.sha2.Sha256, request: Common.StaticDataRequest) void { + writeConstData(hasher, request.const_locator); + writeOptionalConstNodeId(hasher, request.node); + writeCheckedTypeId(hasher, request.checked_type); +} + +fn writeOptionalConstNodeId(hasher: *std.crypto.hash.sha2.Sha256, maybe_node: ?checked.ConstNodeId) void { + if (maybe_node) |node| { + writeHashBool(hasher, true); + writeHashU32(hasher, @intFromEnum(node)); + } else { + writeHashBool(hasher, false); + } } fn writeConstData(hasher: *std.crypto.hash.sha2.Sha256, data: anytype) void { @@ -2500,6 +2524,7 @@ fn expectEquivalentProgramViews( try std.testing.expectEqualSlices(Ast.Root, fresh.roots, mapped.roots); try std.testing.expectEqualSlices(Ast.LayoutRequest, fresh.layout_requests, mapped.layout_requests); try std.testing.expectEqualSlices(Ast.RuntimeSchemaRequest, fresh.runtime_schema_requests, mapped.runtime_schema_requests); + try std.testing.expectEqualSlices(Ast.StaticDataValue, fresh.static_data_values, mapped.static_data_values); try std.testing.expectEqualSlices(Base.SourceLoc, fresh.expr_locs, mapped.expr_locs); try std.testing.expectEqualSlices(Base.Region, fresh.expr_regions, mapped.expr_regions); try std.testing.expectEqualSlices(Base.SourceLoc, fresh.stmt_locs, mapped.stmt_locs); diff --git a/src/postcheck/monotype/solve.zig b/src/postcheck/monotype/solve.zig index d69460a8b9e..4b05398ee95 100644 --- a/src/postcheck/monotype/solve.zig +++ b/src/postcheck/monotype/solve.zig @@ -678,11 +678,29 @@ pub const InstGraph = struct { try self.unifyThroughBacking(right, right_content, left, pending); return; } + if (isForcedDynamicIteratorPair(left_named, right_named)) { + if (left_named.args.len == 0 or right_named.args.len == 0) { + Common.invariant("forced-dynamic iterator reached Monotype instantiation without a public item argument"); + } + try pending.append(self.allocator, .{ + .left = left_named.args[0], + .right = right_named.args[0], + }); + if (left_named.def.iterator_representation == .forced_dynamic) { + try self.union_(left, right); + } else { + try self.union_(right, left); + } + return; + } if (std.meta.eql(left_named.def, right_named.def) and left_named.args.len == right_named.args.len) { for (left_named.args, right_named.args) |left_arg, right_arg| { try pending.append(self.allocator, .{ .left = left_arg, .right = right_arg }); } - if (!sameBuiltinOwner(left_named.builtin_owner, right_named.builtin_owner, .fields)) { + if (!sameBuiltinOwner(left_named.builtin_owner, right_named.builtin_owner, .fields) and + !eitherBuiltinOwner(left_named.builtin_owner, right_named.builtin_owner, .iter) and + !eitherBuiltinOwner(left_named.builtin_owner, right_named.builtin_owner, .stream)) + { if (left_named.backing) |left_backing| { if (right_named.backing) |right_backing| { try pending.append(self.allocator, .{ .left = left_backing.node, .right = right_backing.node }); @@ -846,7 +864,17 @@ pub const InstGraph = struct { fn sameTypeDef(left: Type.TypeDef, right: Type.TypeDef) bool { return left.module == right.module and - left.type_name == right.type_name; + left.type_name == right.type_name and + left.source_decl == right.source_decl and + optionalDigestEql(left.generated, right.generated) and + left.iterator_representation == right.iterator_representation and + left.iterator_depth == right.iterator_depth; + } + + fn optionalDigestEql(left: ?names.TypeDigest, right: ?names.TypeDigest) bool { + if (left == null and right == null) return true; + if (left == null or right == null) return false; + return std.mem.eql(u8, left.?.bytes[0..], right.?.bytes[0..]); } const RowKind = enum { @@ -1588,10 +1616,10 @@ pub const InstGraph = struct { ) Allocator.Error!Type.TypeId { const ty = existing orelse return try self.monoFor(node); const root = self.find(node); - const previous_root = if (self.mono_nodes.get(ty)) |mapped| self.find(mapped) else null; + const previous_root = self.monoViewNode(ty) orelse return try self.monoFor(root); try self.mono_nodes.put(ty, root); try self.registerMonoView(root, ty); - if (previous_root == null or previous_root.? != root) { + if (previous_root != root) { try self.queueDirty(root); } return ty; @@ -1602,15 +1630,17 @@ pub const InstGraph = struct { nodes_slice: []const NodeId, existing: ?Type.Span, ) Allocator.Error![]Type.TypeId { + const existing_copy = if (existing) |old_span| blk: { + const old = self.types.span(old_span); + break :blk if (old.len == nodes_slice.len) try GuardedList.dupe(self.allocator, Type.TypeId, old) else null; + } else null; + defer if (existing_copy) |old| self.allocator.free(old); + const out = try self.arena().alloc(Type.TypeId, nodes_slice.len); for (nodes_slice, 0..) |node, index| { - const existing_ty: ?Type.TypeId = if (existing) |span| existing_ty: { - const old = self.types.span(span); - break :existing_ty if (old.len == nodes_slice.len) GuardedList.at(old, index) else null; - } else null; out[index] = try self.monoForWithReuse( node, - existing_ty, + if (existing_copy) |old| old[index] else null, ); } return out; @@ -1767,6 +1797,11 @@ pub const GraphTypeFinals = struct { } pub fn sealType(self: *GraphTypeFinals, ty: Type.TypeId) Allocator.Error!Type.TypeId { + if (self.isGeneratedIteratorEvidenceType(ty)) { + if (self.graph.monoViewNode(ty) != null) return try self.sealStoreType(ty); + if (try self.typeHasGraphViews(ty)) return try self.sealStoreType(ty); + return ty; + } if (self.graph.mono_nodes.get(ty)) |raw_node| { const node = self.graph.find(raw_node); if (self.graph.node_monos.get(node)) |views| { @@ -1779,6 +1814,19 @@ pub const GraphTypeFinals = struct { return ty; } + fn isGeneratedIteratorEvidenceType(self: *GraphTypeFinals, ty: Type.TypeId) bool { + return switch (self.graph.types.get(ty)) { + .named => |named| switch (named.def.iterator_representation) { + .minted, .forced_dynamic => switch (named.builtin_owner orelse return false) { + .iter, .stream => true, + else => false, + }, + .none => false, + }, + else => false, + }; + } + pub fn sealNode(self: *GraphTypeFinals, raw_node: NodeId) Allocator.Error!Type.TypeId { const node = self.graph.find(raw_node); if (self.sealed.get(node)) |existing| return existing; @@ -2214,6 +2262,41 @@ fn sameBuiltinOwner(left: ?static_dispatch.BuiltinOwner, right: ?static_dispatch return left_owner == owner and right_owner == owner; } +fn eitherBuiltinOwner(left: ?static_dispatch.BuiltinOwner, right: ?static_dispatch.BuiltinOwner, owner: static_dispatch.BuiltinOwner) bool { + if (left) |left_owner| { + if (left_owner == owner) return true; + } + if (right) |right_owner| { + if (right_owner == owner) return true; + } + return false; +} + +fn isForcedDynamicIteratorPair(left: InstNamed, right: InstNamed) bool { + if (left.kind != right.kind) return false; + if (left.def.module != right.def.module or + left.def.type_name != right.def.type_name or + left.def.source_decl != right.def.source_decl) + { + return false; + } + if (!isIteratorLikeOwnerPair(left.builtin_owner, right.builtin_owner)) return false; + return (left.def.iterator_representation == .forced_dynamic) != + (right.def.iterator_representation == .forced_dynamic); +} + +fn isIteratorLikeOwnerPair(left: ?static_dispatch.BuiltinOwner, right: ?static_dispatch.BuiltinOwner) bool { + const owner = left orelse right orelse return false; + if (owner != .iter and owner != .stream) return false; + if (left) |left_owner| { + if (left_owner != owner) return false; + } + if (right) |right_owner| { + if (right_owner != owner) return false; + } + return true; +} + fn testCheckedTypeId(comptime value: u32) checked.CheckedTypeId { comptime std.debug.assert(value != 0); return @enumFromInt(value); diff --git a/src/postcheck/monotype/type.zig b/src/postcheck/monotype/type.zig index 88efb50f0ab..c0fd0f561a3 100644 --- a/src/postcheck/monotype/type.zig +++ b/src/postcheck/monotype/type.zig @@ -61,6 +61,21 @@ pub const TypeDef = struct { /// Declaring statement in the (content-identified) module: the /// within-module discriminator for same-named block-local declarations. source_decl: ?u32 = null, + /// Compiler-generated specialization identity for internal nominals minted + /// from a public source nominal. Null means this is the source nominal. + generated: ?names.TypeDigest = null, + /// Representation decision produced when an internal iterator nominal is + /// created. Later stages consume the recorded tier and mint depth directly. + iterator_representation: IteratorRepresentation = .none, + /// Producer-computed minted-chain depth. Meaningful only for `.minted`. + iterator_depth: u8 = 0, +}; + +/// Explicit representation tier assigned when an iterator nominal is created. +pub const IteratorRepresentation = enum(u8) { + none, + minted, + forced_dynamic, }; /// Named checked type instance. @@ -834,6 +849,9 @@ pub const Store = struct { if (named.def.source_decl == null) { writeBytes(hasher, name_store.typeNameText(named.def.type_name)); } + writeOptionalDigest(hasher, named.def.generated); + writeBytes(hasher, @tagName(named.def.iterator_representation)); + writeU32(hasher, named.def.iterator_depth); writeBytes(hasher, @tagName(named.kind)); if (named.builtin_owner) |owner| { writeBytes(hasher, "builtin"); @@ -992,6 +1010,9 @@ pub const Store = struct { if (named.def.source_decl == null) { writeBytes(hasher, name_store.typeNameText(named.def.type_name)); } + writeOptionalDigest(hasher, named.def.generated); + writeBytes(hasher, @tagName(named.def.iterator_representation)); + writeU32(hasher, named.def.iterator_depth); writeBytes(hasher, @tagName(named.kind)); if (named.builtin_owner) |owner| { writeBytes(hasher, "builtin"); @@ -1216,6 +1237,9 @@ fn namedTypeViewEql( { return false; } + if (!optionalDigestEql(lhs.def.generated, rhs.def.generated)) return false; + if (lhs.def.iterator_representation != rhs.def.iterator_representation) return false; + if (lhs.def.iterator_depth != rhs.def.iterator_depth) return false; if (lhs.builtin_owner != rhs.builtin_owner) return false; if (!try typeSpanViewEql(type_view, name_store, lhs.args, rhs.args, visited)) return false; @@ -1532,6 +1556,9 @@ fn namedTypeEqlAcrossStores( { return false; } + if (!optionalDigestEql(lhs.def.generated, rhs.def.generated)) return false; + if (lhs.def.iterator_representation != rhs.def.iterator_representation) return false; + if (lhs.def.iterator_depth != rhs.def.iterator_depth) return false; if (lhs.builtin_owner != rhs.builtin_owner) return false; if (!try typeSpanEqlAcrossStores(name_store, lhs_view, lhs.args, rhs_view, rhs.args, visited)) return false; @@ -2116,6 +2143,18 @@ fn writeOptionalU32(hasher: *std.crypto.hash.sha2.Sha256, value: ?u32) void { if (value) |v| writeU32(hasher, v); } +fn writeOptionalDigest(hasher: *std.crypto.hash.sha2.Sha256, value: ?names.TypeDigest) void { + const present: u8 = if (value == null) 0 else 1; + hasher.update(std.mem.asBytes(&present)); + if (value) |v| hasher.update(&v.bytes); +} + +fn optionalDigestEql(lhs: ?names.TypeDigest, rhs: ?names.TypeDigest) bool { + if (lhs == null and rhs == null) return true; + if (lhs == null or rhs == null) return false; + return std.mem.eql(u8, lhs.?.bytes[0..], rhs.?.bytes[0..]); +} + fn builtinOwner(primitive: Primitive) static_dispatch.BuiltinOwner { return switch (primitive) { .bool => .bool, diff --git a/src/postcheck/monotype_lifted/ast.zig b/src/postcheck/monotype_lifted/ast.zig index f224e3b8d5d..1f46cb2d497 100644 --- a/src/postcheck/monotype_lifted/ast.zig +++ b/src/postcheck/monotype_lifted/ast.zig @@ -126,6 +126,8 @@ pub const LayoutRequest = struct { /// Runtime schema requested for a named runtime value shape. pub const RuntimeSchemaRequest = Mono.RuntimeSchemaRequest; +/// Request to make a lifted value available as static data. +pub const StaticDataValue = Mono.StaticDataValue; /// Function imported from another Monotype shard. pub const ImportedFn = Mono.ImportedFn; /// Identifier for an imported function table entry. @@ -162,6 +164,7 @@ pub const ProgramView = struct { roots: []const Root, layout_requests: []const LayoutRequest, runtime_schema_requests: []const RuntimeSchemaRequest, + static_data_values: []const StaticDataValue, comptime_sites: []const ComptimeSite, source_files: []const []const u8, expr_locs: []const base.SourceLoc, @@ -390,6 +393,7 @@ pub const Program = struct { roots: ProgramList(Root, "roots"), layout_requests: ProgramList(LayoutRequest, "layout_requests"), runtime_schema_requests: ProgramList(RuntimeSchemaRequest, "runtime_schema_requests"), + static_data_values: ProgramList(StaticDataValue, "static_data_values"), comptime_sites: ProgramList(ComptimeSite, "comptime_sites"), /// Source file table for `SourceLoc.file` indices (moved from Monotype). source_files: ProgramList([]const u8, "source_files"), @@ -437,6 +441,7 @@ pub const Program = struct { stmt_locs: std.ArrayList(base.SourceLoc), stmt_regions: std.ArrayList(base.Region), local_names: std.ArrayList([]const u8), + static_data_values: std.ArrayList(StaticDataValue), comptime_sites: std.ArrayList(ComptimeSite), next_symbol: u32, ) Program { @@ -468,6 +473,7 @@ pub const Program = struct { .roots = .empty, .layout_requests = .empty, .runtime_schema_requests = .empty, + .static_data_values = ProgramList(StaticDataValue, "static_data_values").fromArrayList(static_data_values), .comptime_sites = ProgramList(ComptimeSite, "comptime_sites").fromArrayList(comptime_sites), .source_files = ProgramList([]const u8, "source_files").fromArrayList(source_files), .expr_locs = ProgramList(base.SourceLoc, "expr_locs").fromArrayList(expr_locs), @@ -495,6 +501,7 @@ pub const Program = struct { self.allocator.free(site.branch_regions); } self.comptime_sites.deinit(self.allocator); + self.static_data_values.deinit(self.allocator); self.runtime_schema_requests.deinit(self.allocator); self.layout_requests.deinit(self.allocator); self.roots.deinit(self.allocator); @@ -549,6 +556,7 @@ pub const Program = struct { .roots = self.roots.unsafeRawItemsForView(), .layout_requests = self.layout_requests.unsafeRawItemsForView(), .runtime_schema_requests = self.runtime_schema_requests.unsafeRawItemsForView(), + .static_data_values = self.static_data_values.unsafeRawItemsForView(), .comptime_sites = self.comptime_sites.unsafeRawItemsForView(), .source_files = self.source_files.unsafeRawItemsForView(), .expr_locs = self.expr_locs.unsafeRawItemsForView(), @@ -662,6 +670,10 @@ pub const Program = struct { return self.source_files.takeArrayList(); } + pub fn takeStaticDataValues(self: *Program) std.ArrayList(StaticDataValue) { + return self.static_data_values.takeArrayList(); + } + pub fn stringLiteralsView(self: *const Program) []const Mono.StringLiteral { return self.string_literals.unsafeRawItemsForView(); } diff --git a/src/postcheck/monotype_lifted/lift.zig b/src/postcheck/monotype_lifted/lift.zig index 66faec6e862..26fd9dd4481 100644 --- a/src/postcheck/monotype_lifted/lift.zig +++ b/src/postcheck/monotype_lifted/lift.zig @@ -50,6 +50,7 @@ pub fn run( var stmt_locs = owned.stmt_locs.takeArrayList(); var stmt_regions = owned.stmt_regions.takeArrayList(); var local_names = owned.local_names.takeArrayList(); + var static_data_values = owned.static_data_values.takeArrayList(); var program = Ast.Program.init( allocator, @@ -78,6 +79,7 @@ pub fn run( stmt_locs, stmt_regions, local_names, + static_data_values, comptime_sites, owned.next_symbol, ); @@ -106,6 +108,7 @@ pub fn run( stmt_locs = undefined; stmt_regions = undefined; local_names = undefined; + static_data_values = undefined; comptime_sites = undefined; program.runtime_schema_requests = Ast.ProgramList(Ast.RuntimeSchemaRequest, "runtime_schema_requests").fromArrayList(runtime_schema_requests); runtime_schema_requests = undefined; @@ -158,6 +161,7 @@ fn movedMonoView(source: *const Mono.Program, moved: *const Ast.Program) Mono.Pr .roots = source_view.roots, .layout_requests = source_view.layout_requests, .runtime_schema_requests = moved_view.runtime_schema_requests, + .static_data_values = moved_view.static_data_values, .comptime_sites = moved_view.comptime_sites, .source_files = moved_view.source_files, .expr_locs = moved_view.expr_locs, @@ -571,6 +575,7 @@ const Lifter = struct { => |items| try self.rewriteExprSpan(items), .record => |fields| try self.rewriteFieldExprSpan(fields), .tag => |tag| try self.rewriteExprSpan(tag.payloads), + .static_data_candidate => |candidate| try self.rewriteExpr(candidate.runtime_expr), .nominal, .dbg, .expect, @@ -901,17 +906,27 @@ fn deinitCaptureTable(allocator: Allocator, captures: []std.ArrayList(Ast.TypedL /// Find the existing operand value that supplies capture `id`. /// -/// For a plain local read whose value-local carries a CaptureId, that CaptureId -/// is authoritative — not the operand's stored id: spec_constr substitution can -/// replace an operand's value with a local of a different capture while leaving -/// the stored id stale, so the value's current identity wins. +/// An operand carries two identities: the value's own CaptureId (when its value +/// is a plain local that carries one) and the operand's declared `id` (set when +/// the operand was built to fill a specific target slot). They agree for a +/// pass-through capture, but diverge in two ways this join must both handle: /// -/// When the value-local carries no CaptureId (e.g. a spec_constr-minted arg or -/// temp local produced by argument splitting/inlining), no value identity is -/// available, so the operand's stored id — set when the operand was built for a -/// specific slot — is the only identity and is honored when present. Genuinely -/// explicit (non-local) values likewise fall back to their stored id. A -/// value-id match always takes precedence over the stored id. +/// - A value-local whose CaptureId equals `id` is the exact supply for the +/// slot and wins outright. This also overrides a stale declared id: when +/// spec_constr substitutes an operand's value with a local of a different +/// capture but leaves the declared id unchanged, the value's current +/// identity is authoritative. +/// +/// - Otherwise the operand's declared id names the slot it fills, even when +/// its value-local carries a different CaptureId. spec_constr routes a +/// value-local into a slot its own binding does not name — e.g. a +/// destructured successor `rest` passed as the next iterator's inner-state +/// capture — and only the declared id records which slot that is. A +/// value-local with no CaptureId, and a genuinely explicit (non-local) +/// value, are likewise keyed solely by the declared id. +/// +/// An exact value-CaptureId match always takes precedence over a declared-id +/// match; a declared-id match is used only when no exact match exists. fn operandValueForSlotId(program: *const Ast.Program, existing: anytype, id: checked.CaptureId) ?Ast.ExprId { var fallback_by_id: ?Ast.ExprId = null; for (0..existing.len) |index| { @@ -920,6 +935,7 @@ fn operandValueForSlotId(program: *const Ast.Program, existing: anytype, id: che .local => |local| { if (program.getLocal(local).capture_id) |value_id| { if (value_id == id) return operand.value; + if (operand.id == id and fallback_by_id == null) fallback_by_id = operand.value; } else if (operand.id == id and fallback_by_id == null) { fallback_by_id = operand.value; } @@ -1274,6 +1290,7 @@ const CaptureSet = struct { const payloads = input.exprSpan(tag.payloads); for (0..payloads.len) |payload_index| try self.collectExpr(GuardedList.at(payloads, payload_index), bound); }, + .static_data_candidate => |candidate| try self.collectExpr(candidate.runtime_expr, bound), .nominal, .dbg, .expect, diff --git a/src/postcheck/monotype_lifted/spec_constr.zig b/src/postcheck/monotype_lifted/spec_constr.zig index d50b9fd1ba6..d9b5b107288 100644 --- a/src/postcheck/monotype_lifted/spec_constr.zig +++ b/src/postcheck/monotype_lifted/spec_constr.zig @@ -145,7 +145,7 @@ //! `Stream.map`, with captures for the source step thunk and the mapping //! function. Each `One` or `Skip` branch constructs the same mapped stream shape //! for the next iteration. Without this pass, the compiler lowers that as a loop -//! over a single stream value, repacking stream fields and rebuilding the step +//! over a single stream value, repacking stream fields and building the step //! closure before immediately reading them again. //! //! This pass specializes the collect worker for the known stream shape. Written @@ -269,6 +269,7 @@ const CallableShape = struct { const Value = union(enum) { expr: Ast.ExprId, + static_data_candidate: StaticDataCandidateValue, tag: TagValue, record: RecordValue, tuple: TupleValue, @@ -276,6 +277,12 @@ const Value = union(enum) { callable: CallableValue, }; +const StaticDataCandidateValue = struct { + ty: Type.TypeId, + static_data: Common.StaticDataId, + runtime: *const Value, +}; + const TagValue = struct { ty: Type.TypeId, name: names.TagNameId, @@ -302,10 +309,6 @@ const NominalValue = struct { backing: *const Value, }; -/// A cloned capture operand carried on a callable value: its exact -/// CaptureId plus the cloned supplying value. Keeping the id lets materialization -/// pair operands to the (possibly specialized) target's sorted capture slots by -/// id rather than by position. const CaptureValue = struct { id: check.CheckedModule.CaptureId, value: Value, @@ -337,9 +340,19 @@ const FnPlan = struct { } }; +/// A pattern binder paired with the monomorphic type it was bound at. A single +/// source binder is reused across every monomorphization of its binding, so the +/// binder alone does not identify a value; the type digest completes the +/// identity, matching the `(binder, type)` identity Monotype lowering uses for +/// locals. See `Builder.sameLocalIdentity` in monotype/lower.zig. +const BinderIdentity = struct { + binder: check.CheckedModule.PatternBinderId, + digest: names.TypeDigest, +}; + const BindingTarget = union(enum) { local: Ast.LocalId, - binder: check.CheckedModule.PatternBinderId, + binder: BinderIdentity, }; const BindingChange = struct { @@ -351,10 +364,28 @@ const PendingLet = struct { local: Ast.LocalId, ty: Type.TypeId, value: Ast.ExprId, + /// The cloner's effect-mark count when this binding was created. A + /// binding created after an effect was emitted in its region must not + /// move to the region's start, because it would cross that effect. + marks: usize, }; const LoopPattern = struct { - values: []const Shape, + /// The entry shape of each carried slot, split into leaves the back edges + /// supply. A back edge that cannot supply one leaf demotes that leaf (not + /// the whole slot) to `.any` in place, keeping its sibling leaves split. + values: []Shape, + /// Set by any back edge that demoted a leaf during a split attempt. The + /// attempt's owner reads this after cloning the body, discards the clone, + /// and retries with the demoted leaves carried as runtime scalars. + any_demoted: bool, +}; + +/// The result of supplying one loop slot's leaves from a back edge: the +/// (possibly demoted) shape and whether any leaf demoted to `.any`. +const SuppliedSlot = struct { + shape: Shape, + demoted: bool, }; const ActiveCallable = struct { @@ -362,12 +393,38 @@ const ActiveCallable = struct { specialized: Ast.FnId, }; +/// A function currently being inlined, with the number of known-constructor +/// nodes carried by the call's arguments and captures. A same-function call +/// nested inside its own inlining may re-enter only when its known-constructor +/// arguments are strictly smaller, which is what lets an adapter's step inline +/// `Iter.next` on its own inner iterator (one adapter layer smaller) while +/// still terminating: the measure strictly decreases and the base iterator's +/// step calls no further `next`. +const InlineFrame = struct { + fn_id: Ast.FnId, + known_size: usize, +}; + const Pass = struct { allocator: Allocator, arena: std.heap.ArenaAllocator, program: *Ast.Program, plans: []FnPlan, symbols: Common.SymbolGen, + /// Per source function: whether its body performs no observable effect. + /// Calls to such functions are pure computations; only calls to the + /// rest carry effects. Functions created during specialization are past + /// the end of this table and count as effectful. + fn_effect_free: []bool, + /// Per source function: whether the branch-append peel rewrote its body + /// before specialization. A peeled body iterates the shared base directly, + /// so it no longer reads as branch-chosen, but its base loop still needs the + /// whole-body scalarizing clone. + peeled: []bool, + /// Functions that directly call themselves. The value-aware call-pattern + /// pass is only needed for these recursive workers, where a `let`-bound + /// constructor argument can select an already-reserved specialization. + self_recursive_fns: []bool, fn init(allocator: Allocator, program: *Ast.Program) Allocator.Error!Pass { var arena = std.heap.ArenaAllocator.init(allocator); @@ -388,16 +445,60 @@ const Pass = struct { }; } + const fn_effect_free = try allocator.alloc(bool, program.fnCount()); + errdefer allocator.free(fn_effect_free); + for (0..program.fnCount()) |index| { + const fn_ = program.getFnAt(index); + fn_effect_free[index] = fn_.body == .roc; + } + var changed = true; + while (changed) { + changed = false; + for (0..program.fnCount()) |index| { + const fn_ = program.getFnAt(index); + if (!fn_effect_free[index]) continue; + const body = switch (fn_.body) { + .roc => |body| body, + .hosted => continue, + }; + if (!exprHasNoObservableEffect(program, fn_effect_free, body, true)) { + fn_effect_free[index] = false; + changed = true; + } + } + } + + const peeled = try allocator.alloc(bool, program.fnCount()); + errdefer allocator.free(peeled); + @memset(peeled, false); + + const self_recursive_fns = try allocator.alloc(bool, program.fnCount()); + errdefer allocator.free(self_recursive_fns); + for (0..program.fnCount()) |index| { + const fn_id: Ast.FnId = @enumFromInt(@as(u32, @intCast(index))); + const fn_ = program.getFnAt(index); + self_recursive_fns[index] = switch (fn_.body) { + .roc => |body| exprCallsFn(program, body, fn_id), + .hosted => false, + }; + } + return .{ .allocator = allocator, .arena = arena, .program = program, .plans = plans, .symbols = .{ .next = program.next_symbol }, + .fn_effect_free = fn_effect_free, + .peeled = peeled, + .self_recursive_fns = self_recursive_fns, }; } fn deinit(self: *Pass) void { + self.allocator.free(self.self_recursive_fns); + self.allocator.free(self.fn_effect_free); + self.allocator.free(self.peeled); for (self.plans) |*plan| plan.deinit(self.allocator); self.allocator.free(self.plans); self.arena.deinit(); @@ -406,16 +507,50 @@ const Pass = struct { fn run(self: *Pass) Common.LowerError!void { const original_fn_count = self.plans.len; + // The append peel runs first, while an append chain's arms are still + // separate `append` calls over their shared base. Specialization would + // otherwise collapse a multi-append arm into one specialized adapter + // worker the peel could not unwrap. + try self.peelBranchAppendLoops(original_fn_count); try self.collectArgUses(original_fn_count); try self.collectCallPatterns(original_fn_count); + try self.collectValueAwareCallPatterns(original_fn_count); try self.reserveSpecIds(); try self.createSpecializations(original_fn_count); try self.rewriteExistingCalls(); + try self.rewriteValueAwareCalls(); + try self.scalarizeIteratorLoops(original_fn_count); try Lift.recomputeCaptures(self.allocator, self.program); self.program.next_symbol = self.symbols.next; } + /// Rewrite each branch-chosen `append`-loop function into a base loop plus a + /// branch-dispatched tail, before specialization can collapse its arms. + /// Records which functions were rewritten so their base loops still get the + /// whole-body scalarizing clone later. + fn peelBranchAppendLoops(self: *Pass, original_fn_count: usize) Common.LowerError!void { + for (0..original_fn_count) |index| { + const fn_ = self.program.getFnAt(index); + const body = switch (fn_.body) { + .roc => |body| body, + .hosted => continue, + }; + if (!try self.bodyHasBranchChosenIterLoop(body)) continue; + if (try self.peelBranchAppendBody(body)) |peeled| { + self.program.setFnAt(index, .{ + .symbol = fn_.symbol, + .source = fn_.source, + .args = fn_.args, + .captures = fn_.captures, + .body = .{ .roc = peeled }, + .ret = fn_.ret, + }); + self.peeled[index] = true; + } + } + } + fn copyProcDebugName(self: *Pass, source_symbol: Common.Symbol, target_symbol: Common.Symbol) Allocator.Error!void { if (self.program.procDebugName(source_symbol)) |name| { try self.program.setProcDebugName(target_symbol, name); @@ -451,6 +586,26 @@ const Pass = struct { } } + /// The syntax-directed collector above cannot see that a recursive + /// direct-call argument is known when it is first named by a `let`. Walk + /// with the cloner's substitution environment so those calls still reserve + /// workers. + fn collectValueAwareCallPatterns(self: *Pass, original_fn_count: usize) Common.LowerError!void { + var index: usize = 0; + while (index < original_fn_count) : (index += 1) { + const fn_ = self.program.getFnAt(index); + const body = switch (fn_.body) { + .roc => |body| body, + .hosted => continue, + }; + const fn_id: Ast.FnId = @enumFromInt(@as(u32, @intCast(index))); + var cloner = Cloner.initForRewrite(self); + cloner.rewrite_call_patterns = false; + defer cloner.deinit(); + try cloner.collectCallPatternsInExpr(fn_id, body); + } + } + fn reserveSpecIds(self: *Pass) Allocator.Error!void { for (self.plans, 0..) |*plan, source_index| { const source_fn = self.program.getFnAt(source_index); @@ -522,6 +677,7 @@ const Pass = struct { const payloads = self.program.exprSpan(tag.payloads); for (0..payloads.len) |index| try self.markArgUsesInExpr(fn_id, GuardedList.at(payloads, index), changed); }, + .static_data_candidate => |candidate| try self.markArgUsesInExpr(fn_id, candidate.runtime_expr, changed), .nominal, .dbg, .expect, @@ -679,6 +835,7 @@ const Pass = struct { => |items| try self.collectCallPatternsInExprSpan(owner, items), .record => |fields| try self.collectCallPatternsInFieldExprSpan(owner, fields), .tag => |tag| try self.collectCallPatternsInExprSpan(owner, tag.payloads), + .static_data_candidate => |candidate| try self.collectCallPatternsInExpr(owner, candidate.runtime_expr), .nominal, .dbg, .expect, @@ -817,10 +974,7 @@ const Pass = struct { for (args, 0..) |arg, index| { if (self.plans[raw].used_args[index]) { - var cloner = Cloner.initForRewrite(self); - defer cloner.deinit(); - const value = try cloner.cloneExprValue(arg); - if (try self.shapeFromValue(value)) |shape| { + if (try self.constructorShape(arg)) |shape| { shapes[index] = shape; has_constructor = true; continue; @@ -841,6 +995,39 @@ const Pass = struct { }); } + fn recordCallPatternForValues(self: *Pass, fn_id: Ast.FnId, values: []const Value) Common.LowerError!void { + const raw = @intFromEnum(fn_id); + if (raw >= self.plans.len) return; + + const fn_args = self.program.typedLocalSpan(self.program.getFnAt(raw).args); + if (values.len != fn_args.len) Common.invariant("direct call arity differed from lifted function arity"); + + const shapes = try self.arena.allocator().alloc(Shape, values.len); + var has_constructor = false; + + for (values, 0..) |value, index| { + if (self.plans[raw].used_args[index]) { + if (try self.shapeFromValue(value)) |shape| { + shapes[index] = shape; + has_constructor = true; + continue; + } + } + shapes[index] = .{ .any = valueType(self.program, value) }; + } + + if (!has_constructor) return; + + const pattern: CallPattern = .{ .args = shapes }; + for (self.plans[raw].specs.items) |spec| { + if (patternEql(self.program, spec.pattern, pattern)) return; + } + + try self.plans[raw].specs.append(self.allocator, .{ + .pattern = pattern, + }); + } + fn ensureCallPatternForValues(self: *Pass, fn_id: Ast.FnId, values: []const Value) Common.LowerError!void { const raw = @intFromEnum(fn_id); if (raw >= self.plans.len) return; @@ -894,10 +1081,10 @@ const Pass = struct { var cloner = Cloner.init(self, source_fn_id, spec.pattern); defer cloner.deinit(); - try cloner.inline_stack.append(self.allocator, source_fn_id); + try cloner.inline_stack.append(self.allocator, .{ .fn_id = source_fn_id, .known_size = 0 }); defer { const popped = cloner.inline_stack.pop() orelse Common.invariant("call-pattern inline stack underflow while writing specialization"); - if (popped != source_fn_id) Common.invariant("call-pattern inline stack was corrupted while writing specialization"); + if (popped.fn_id != source_fn_id) Common.invariant("call-pattern inline stack was corrupted while writing specialization"); } const args = try cloner.buildArgs(); @@ -933,456 +1120,2587 @@ const Pass = struct { } } - fn rewriteCallsInExpr(self: *Pass, expr_id: Ast.ExprId, done: []bool) Allocator.Error!void { - const index = @intFromEnum(expr_id); - if (done[index]) return; - done[index] = true; + /// Detect recursive call sites that only match a worker after `let` + /// substitutions are visible, then clone the whole body through the value + /// pass. Cloning the body dissolves the now-split construction bindings + /// instead of leaving their strict runtime construction behind. + fn rewriteValueAwareCalls(self: *Pass) Common.LowerError!void { + const fn_count = self.program.fnCount(); + for (0..fn_count) |index| { + const fn_ = self.program.getFnAt(index); + const body = switch (fn_.body) { + .roc => |body| body, + .hosted => continue, + }; + const fn_id: Ast.FnId = @enumFromInt(@as(u32, @intCast(index))); + var cloner = Cloner.initForRewrite(self); + cloner.value_aware_detect_only = true; + defer cloner.deinit(); + try cloner.rewriteCallsWithValuesInExpr(body); + if (cloner.value_aware_rewrite_changed) { + try self.cloneFnBodyInPlace(fn_id, body); + } + } + } - const expr = self.program.getExprAt(index); + /// A `for` over a statically known source lowers to a loop carried directly + /// in its enclosing function; it never becomes a call-pattern worker, so + /// nothing else scalarizes it. Two source shapes are handled here. + /// + /// A loop over an owned construction (`for x in [1, 2, 3].iter()`) has its + /// loop expression cloned through the value pass in place, so its + /// loop-carried iterator construction inlines and its state splits into + /// scalars — the same transformation a specialized collect worker receives. + /// + /// A loop over an iterator named by an enclosing `if`/`match` binding (`for x + /// in collision_points`, where `collision_points` chose between `base` and + /// `base.append(..)`) has the whole enclosing body cloned instead, so the + /// value pass sinks the loop into each branch — where that branch's iterator + /// constructor is known — and scalarizes each sunk loop over its own source. + /// + /// Only original function bodies are scanned. A specialized worker's loop + /// already passed through loop-state cloning while the worker was written. + fn scalarizeIteratorLoops(self: *Pass, original_fn_count: usize) Common.LowerError!void { + for (0..original_fn_count) |index| { + const fn_id: Ast.FnId = @enumFromInt(@as(u32, @intCast(index))); + const body = switch (self.program.getFnAt(index).body) { + .roc => |body| body, + .hosted => continue, + }; + + // A `for` over a branch-chosen or `append`-style iterator reads its + // source through a local the enclosing scope bound to an `if`/`match`. + // Cloning only the loop leaves that construction a residual value; the + // whole body must clone so the value pass sinks the loop into each + // branch (where the branch's constructor is known) and scalarizes each + // sunk loop over its own source. Cloning the whole body also carries + // any owned-construction loop it holds, so those are not collected + // separately for it. + // A peeled body iterates the shared base directly; scalarize its base + // loop with the same whole-body clone the branch-chosen shape uses. + if (self.peeled[index] or + try self.bodyHasBranchChosenIterLoop(body) or + try self.bodyHasLocalIterLoop(body)) + { + try self.cloneFnBodyInPlace(fn_id, body); + continue; + } + + var loops = std.ArrayList(Ast.ExprId).empty; + defer loops.deinit(self.allocator); + try self.collectIteratorLoops(body, &loops); + for (loops.items) |loop_id| try self.cloneLoopInPlace(loop_id); + } + } + + /// Whether a function body holds a `for` loop over an iterator named by an + /// enclosing `if`/`match` binding — the branch-chosen (tier-two) shape. The + /// loop's first carried value is an identity-style construction over a single + /// local, and that local is bound in scope to a branch expression whose arms + /// are the differently-shaped iterators the loop must specialize over. + fn bodyHasBranchChosenIterLoop(self: *Pass, body: Ast.ExprId) Allocator.Error!bool { + var branch_bound = std.AutoHashMap(Ast.LocalId, void).init(self.allocator); + defer branch_bound.deinit(); + try self.collectBranchBoundLocals(body, &branch_bound); + if (branch_bound.count() == 0) return false; + return self.loopConsumesBranchBoundLocal(body, &branch_bound); + } + + /// Whether a function body holds a `for` loop over an iterator local whose + /// value is bound in the same body to a direct construction call, such as + /// `xs.iter().append(a).append(b)`. + fn bodyHasLocalIterLoop(self: *Pass, body: Ast.ExprId) Allocator.Error!bool { + var construction_bound = std.AutoHashMap(Ast.LocalId, usize).init(self.allocator); + defer construction_bound.deinit(); + try self.collectConstructionBoundLocals(body, &construction_bound); + if (construction_bound.count() == 0) return false; + return self.iteratorLoopConsumesConstructionBoundLocal(body, &construction_bound); + } + + /// Record every local bound (in a block statement or a `let` expression) to + /// an `if`/`match` whose branches build iterator values — the sources a + /// branch-chosen `for` loop consumes. + fn collectBranchBoundLocals( + self: *Pass, + expr_id: Ast.ExprId, + out: *std.AutoHashMap(Ast.LocalId, void), + ) Allocator.Error!void { + const expr = self.program.getExpr(expr_id); switch (expr.data) { - .local, - .unit, - .int_lit, - .frac_f32_lit, - .frac_f64_lit, - .dec_lit, - .str_lit, - .bytes_lit, - .crash, - .comptime_exhaustiveness_failed, - .uninitialized, - .uninitialized_payload, - => {}, - .fn_ref => |fn_ref| try self.rewriteCallsInCaptureOperandSpan(fn_ref.captures, done), - .list, - .tuple, - => |items| try self.rewriteCallsInExprSpan(items, done), - .record => |fields| try self.rewriteCallsInFieldExprSpan(fields, done), - .tag => |tag| try self.rewriteCallsInExprSpan(tag.payloads, done), - .nominal, - .dbg, - .expect, - => |child| try self.rewriteCallsInExpr(child, done), - .return_ => |ret| try self.rewriteCallsInExpr(ret.value, done), - .expect_err => |expect_err| try self.rewriteCallsInExpr(expect_err.msg, done), - .comptime_branch_taken => |taken| try self.rewriteCallsInExpr(taken.body, done), .let_ => |let_| { - try self.rewriteCallsInExpr(let_.value, done); - try self.rewriteCallsInExpr(let_.rest, done); - }, - .lambda, - .def_ref, - .fn_def, - => Common.invariant("pre-lift function expression reached call-pattern specialization"), - .call_value => |call| { - try self.rewriteCallsInExpr(call.callee, done); - try self.rewriteCallsInExprSpan(call.args, done); + try self.noteBranchBoundBinding(let_.bind, let_.value, out); + try self.collectBranchBoundLocals(let_.value, out); + try self.collectBranchBoundLocals(let_.rest, out); }, - .call_proc => |call| { - try self.rewriteCallsInExprSpan(call.args, done); - try self.rewriteCallsInCaptureOperandSpan(call.captures, done); - try self.rewriteCallProc(expr_id, call); + .block => |block| { + const statements = self.program.stmtSpan(block.statements); + for (0..statements.len) |index| { + const stmt_id = GuardedList.at(statements, index); + switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| { + try self.noteBranchBoundBinding(let_.pat, let_.value, out); + try self.collectBranchBoundLocals(let_.value, out); + }, + .expr, .expect, .dbg => |value| try self.collectBranchBoundLocals(value, out), + .return_ => |ret| try self.collectBranchBoundLocals(ret.value, out), + else => {}, + } + } + try self.collectBranchBoundLocals(block.final_expr, out); }, - .low_level => |call| try self.rewriteCallsInExprSpan(call.args, done), - .field_access => |field| try self.rewriteCallsInExpr(field.receiver, done), - .tuple_access => |access| try self.rewriteCallsInExpr(access.tuple, done), - .structural_eq => |eq| { - try self.rewriteCallsInExpr(eq.lhs, done); - try self.rewriteCallsInExpr(eq.rhs, done); + .loop_ => |loop| { + const initial_values = self.program.exprSpan(loop.initial_values); + for (0..initial_values.len) |index| try self.collectBranchBoundLocals(GuardedList.at(initial_values, index), out); + try self.collectBranchBoundLocals(loop.body, out); }, - .structural_hash => |h| { - try self.rewriteCallsInExpr(h.value, done); - try self.rewriteCallsInExpr(h.hasher, done); + .if_ => |if_| { + const branches = self.program.ifBranchSpan(if_.branches); + for (0..branches.len) |index| try self.collectBranchBoundLocals(GuardedList.at(branches, index).body, out); + try self.collectBranchBoundLocals(if_.final_else, out); }, .match_ => |match| { - try self.rewriteCallsInExpr(match.scrutinee, done); - try self.rewriteCallsInBranchSpan(match.branches, done); + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |index| try self.collectBranchBoundLocals(GuardedList.at(branches, index).body, out); }, - .if_ => |if_| { - try self.rewriteCallsInIfBranchSpan(if_.branches, done); - try self.rewriteCallsInExpr(if_.final_else, done); + .nominal, .dbg, .expect => |child| try self.collectBranchBoundLocals(child, out), + .static_data_candidate => |candidate| try self.collectBranchBoundLocals(candidate.runtime_expr, out), + .return_ => |ret| try self.collectBranchBoundLocals(ret.value, out), + .comptime_branch_taken => |taken| try self.collectBranchBoundLocals(taken.body, out), + else => {}, + } + } + + fn noteBranchBoundBinding( + self: *Pass, + pat_id: Ast.PatId, + value_id: Ast.ExprId, + out: *std.AutoHashMap(Ast.LocalId, void), + ) Allocator.Error!void { + const local = switch (self.program.getPat(pat_id).data) { + .bind => |local| local, + else => return, + }; + switch (self.program.getExpr(value_id).data) { + .if_, .match_ => try out.put(local, {}), + else => {}, + } + } + + /// Record every local bound to a direct construction call. + fn collectConstructionBoundLocals( + self: *Pass, + expr_id: Ast.ExprId, + out: *std.AutoHashMap(Ast.LocalId, usize), + ) Allocator.Error!void { + const expr = self.program.getExpr(expr_id); + switch (expr.data) { + .let_ => |let_| { + try self.noteConstructionBoundBinding(let_.bind, let_.value, out); + try self.collectConstructionBoundLocals(let_.value, out); + try self.collectConstructionBoundLocals(let_.rest, out); }, .block => |block| { - try self.rewriteCallsInStmtSpan(block.statements, done); - try self.rewriteCallsInExpr(block.final_expr, done); + const statements = self.program.stmtSpan(block.statements); + for (0..statements.len) |index| { + const stmt_id = GuardedList.at(statements, index); + switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| { + try self.noteConstructionBoundBinding(let_.pat, let_.value, out); + try self.collectConstructionBoundLocals(let_.value, out); + }, + .expr, .expect, .dbg => |value| try self.collectConstructionBoundLocals(value, out), + .return_ => |ret| try self.collectConstructionBoundLocals(ret.value, out), + else => {}, + } + } + try self.collectConstructionBoundLocals(block.final_expr, out); }, .loop_ => |loop| { - try self.rewriteCallsInExprSpan(loop.initial_values, done); - try self.rewriteCallsInExpr(loop.body, done); - }, - .break_ => |maybe| if (maybe) |value| try self.rewriteCallsInExpr(value, done), - .continue_ => |continue_| try self.rewriteCallsInExprSpan(continue_.values, done), - .if_initialized_payload => |payload_switch| { - try self.rewriteCallsInExpr(payload_switch.cond, done); - try self.rewriteCallsInExpr(payload_switch.initialized, done); - try self.rewriteCallsInExpr(payload_switch.uninitialized, done); + const initial_values = self.program.exprSpan(loop.initial_values); + for (0..initial_values.len) |index| try self.collectConstructionBoundLocals(GuardedList.at(initial_values, index), out); + try self.collectConstructionBoundLocals(loop.body, out); }, - .try_sequence => |sequence| { - try self.rewriteCallsInExpr(sequence.try_expr, done); - try self.rewriteCallsInExpr(sequence.ok_body, done); + .if_ => |if_| { + const branches = self.program.ifBranchSpan(if_.branches); + for (0..branches.len) |index| try self.collectConstructionBoundLocals(GuardedList.at(branches, index).body, out); + try self.collectConstructionBoundLocals(if_.final_else, out); }, - .try_record_sequence => |sequence| { - try self.rewriteCallsInExpr(sequence.try_expr, done); - try self.rewriteCallsInExpr(sequence.ok_body, done); + .match_ => |match| { + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |index| try self.collectConstructionBoundLocals(GuardedList.at(branches, index).body, out); }, + .nominal, .dbg, .expect => |child| try self.collectConstructionBoundLocals(child, out), + .static_data_candidate => |candidate| try self.collectConstructionBoundLocals(candidate.runtime_expr, out), + .return_ => |ret| try self.collectConstructionBoundLocals(ret.value, out), + .comptime_branch_taken => |taken| try self.collectConstructionBoundLocals(taken.body, out), + else => {}, } } - fn rewriteCallsInExprSpan(self: *Pass, span: Ast.Span(Ast.ExprId), done: []bool) Allocator.Error!void { - const source = try GuardedList.dupe(self.allocator, Ast.ExprId, self.program.exprSpan(span)); - defer self.allocator.free(source); - for (source) |expr| try self.rewriteCallsInExpr(expr, done); - } - - fn rewriteCallsInCaptureOperandSpan(self: *Pass, span: Ast.Span(Ast.CaptureOperand), done: []bool) Allocator.Error!void { - const source = try GuardedList.dupe(self.allocator, Ast.CaptureOperand, self.program.captureOperandSpan(span)); - defer self.allocator.free(source); - for (source) |operand| try self.rewriteCallsInExpr(operand.value, done); - } - - fn rewriteCallsInFieldExprSpan(self: *Pass, span: Ast.Span(Ast.FieldExpr), done: []bool) Allocator.Error!void { - const source = try GuardedList.dupe(self.allocator, Ast.FieldExpr, self.program.fieldExprSpan(span)); - defer self.allocator.free(source); - for (source) |field| try self.rewriteCallsInExpr(field.value, done); - } - - fn rewriteCallsInBranchSpan(self: *Pass, span: Ast.Span(Ast.Branch), done: []bool) Allocator.Error!void { - const source = try GuardedList.dupe(self.allocator, Ast.Branch, self.program.branchSpan(span)); - defer self.allocator.free(source); - for (source) |branch| { - if (branch.guard) |guard| try self.rewriteCallsInExpr(guard, done); - try self.rewriteCallsInExpr(branch.body, done); - } + fn noteConstructionBoundBinding( + self: *Pass, + pat_id: Ast.PatId, + value_id: Ast.ExprId, + out: *std.AutoHashMap(Ast.LocalId, usize), + ) Allocator.Error!void { + const local = switch (self.program.getPat(pat_id).data) { + .bind => |local| local, + else => return, + }; + if (!self.localHasIteratorNamedType(local)) return; + var budget: usize = 64; + const depth = self.iteratorConstructionDepth(value_id, out, &budget); + if (depth == 0) return; + try out.put(local, depth); } - fn rewriteCallsInIfBranchSpan(self: *Pass, span: Ast.Span(Ast.IfBranch), done: []bool) Allocator.Error!void { - const source = try GuardedList.dupe(self.allocator, Ast.IfBranch, self.program.ifBranchSpan(span)); - defer self.allocator.free(source); - for (source) |branch| { - try self.rewriteCallsInExpr(branch.cond, done); - try self.rewriteCallsInExpr(branch.body, done); + /// Whether a recognized lowered iterator loop consumes one of the locals + /// bound to a direct iterator construction in the same body. + fn iteratorLoopConsumesConstructionBoundLocal( + self: *Pass, + expr_id: Ast.ExprId, + set: *std.AutoHashMap(Ast.LocalId, usize), + ) Common.LowerError!bool { + const expr = self.program.getExpr(expr_id); + switch (expr.data) { + .loop_ => |loop| { + if (try self.matchIteratorLoopParts(expr_id)) |parts| { + if (set.get(parts.source_local)) |depth| { + if (depth >= 2 and self.localHasIteratorNamedType(parts.source_local)) return true; + } + } + return self.iteratorLoopConsumesConstructionBoundLocal(loop.body, set); + }, + .let_ => |let_| { + return (try self.iteratorLoopConsumesConstructionBoundLocal(let_.value, set)) or + (try self.iteratorLoopConsumesConstructionBoundLocal(let_.rest, set)); + }, + .block => |block| { + const statements = self.program.stmtSpan(block.statements); + for (0..statements.len) |index| { + const stmt_id = GuardedList.at(statements, index); + const found = switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| try self.iteratorLoopConsumesConstructionBoundLocal(let_.value, set), + .expr, .expect, .dbg => |value| try self.iteratorLoopConsumesConstructionBoundLocal(value, set), + .return_ => |ret| try self.iteratorLoopConsumesConstructionBoundLocal(ret.value, set), + else => false, + }; + if (found) return true; + } + return self.iteratorLoopConsumesConstructionBoundLocal(block.final_expr, set); + }, + .if_ => |if_| { + const branches = self.program.ifBranchSpan(if_.branches); + for (0..branches.len) |index| { + if (try self.iteratorLoopConsumesConstructionBoundLocal(GuardedList.at(branches, index).body, set)) return true; + } + return self.iteratorLoopConsumesConstructionBoundLocal(if_.final_else, set); + }, + .match_ => |match| { + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |index| { + if (try self.iteratorLoopConsumesConstructionBoundLocal(GuardedList.at(branches, index).body, set)) return true; + } + return false; + }, + .nominal, .dbg, .expect => |child| return self.iteratorLoopConsumesConstructionBoundLocal(child, set), + .static_data_candidate => |candidate| return self.iteratorLoopConsumesConstructionBoundLocal(candidate.runtime_expr, set), + .return_ => |ret| return self.iteratorLoopConsumesConstructionBoundLocal(ret.value, set), + .comptime_branch_taken => |taken| return self.iteratorLoopConsumesConstructionBoundLocal(taken.body, set), + else => return false, } } - fn rewriteCallsInStmtSpan(self: *Pass, span: Ast.Span(Ast.StmtId), done: []bool) Allocator.Error!void { - const source = try GuardedList.dupe(self.allocator, Ast.StmtId, self.program.stmtSpan(span)); - defer self.allocator.free(source); - for (source) |stmt| try self.rewriteCallsInStmt(stmt, done); - } - - fn rewriteCallsInStmt(self: *Pass, stmt_id: Ast.StmtId, done: []bool) Allocator.Error!void { - switch (self.program.getStmt(stmt_id)) { - .let_ => |let_| try self.rewriteCallsInExpr(let_.value, done), - .expr, - .expect, - .dbg, - => |expr| try self.rewriteCallsInExpr(expr, done), - .return_ => |ret| try self.rewriteCallsInExpr(ret.value, done), - .uninitialized, .crash => {}, - } + fn localHasIteratorNamedType(self: *Pass, local: Ast.LocalId) bool { + const ty = self.program.getLocal(local).ty; + return self.typeIsIteratorNamed(ty); } - fn rewriteCallProc(self: *Pass, expr_id: Ast.ExprId, call: @import("../monotype/ast.zig").CallProc) Allocator.Error!void { - const callee = Ast.localDirectCallee(call) orelse return; - const raw = @intFromEnum(callee); - if (raw >= self.plans.len) return; - if (self.plans[raw].specs.items.len == 0) return; - - const args = try GuardedList.dupe(self.allocator, Ast.ExprId, self.program.exprSpan(call.args)); - defer self.allocator.free(args); - for (self.plans[raw].specs.items) |spec| { - var rewritten_args = std.ArrayList(Ast.ExprId).empty; - defer rewritten_args.deinit(self.allocator); - - if (try self.appendExistingCallArgs(spec.pattern, args, &rewritten_args)) { - self.program.setExprData(expr_id, .{ .call_proc = .{ - .callee = .{ .lifted = spec.fn_id orelse Common.invariant("call-pattern specialization id was not assigned before rewriting") }, - .args = try self.program.addExprSpan(rewritten_args.items), - .captures = call.captures, - .is_cold = call.is_cold, - } }); - return; - } - } + fn typeIsIteratorNamed(self: *Pass, ty: Type.TypeId) bool { + return switch (self.program.types.get(ty)) { + .named => |named| blk: { + const type_name = self.program.names.typeNameText(named.def.type_name); + break :blk named.def.iterator_representation != .none or std.mem.eql(u8, type_name, "Builtin.Iter"); + }, + else => false, + }; } - fn appendExistingCallArgs( + fn iteratorConstructionDepth( self: *Pass, - pattern: CallPattern, - args: []const Ast.ExprId, - out: *std.ArrayList(Ast.ExprId), - ) Allocator.Error!bool { - if (pattern.args.len != args.len) Common.invariant("call-pattern arity differed from direct call arity"); - var cloner = Cloner.initForRewrite(self); - defer cloner.deinit(); + expr_id: Ast.ExprId, + known_depths: *std.AutoHashMap(Ast.LocalId, usize), + budget: *usize, + ) usize { + if (budget.* == 0) return 0; + budget.* -= 1; - for (pattern.args, args) |shape, arg| { - const value = try cloner.cloneExprValue(arg); - if (!shapeMatchesValue(self.program, shape, value)) return false; - try cloner.appendExprsFromValue(shape, value, out); - } - return true; + const expr = self.program.getExpr(expr_id); + return switch (expr.data) { + .local => |local| known_depths.get(local) orelse 0, + .call_proc => |call| blk: { + if (Ast.localDirectCallee(call) == null) break :blk 0; + if (!self.typeIsIteratorNamed(expr.ty)) break :blk 0; + + var inner_depth: usize = 0; + const args = self.program.exprSpan(call.args); + for (0..args.len) |index| { + inner_depth = @max(inner_depth, self.iteratorConstructionDepth(GuardedList.at(args, index), known_depths, budget)); + } + break :blk inner_depth + 1; + }, + .nominal => |backing| self.iteratorConstructionDepth(backing, known_depths, budget), + .block => |block| blk: { + if (self.program.stmtSpan(block.statements).len != 0) break :blk 0; + break :blk self.iteratorConstructionDepth(block.final_expr, known_depths, budget); + }, + .static_data_candidate => |candidate| self.iteratorConstructionDepth(candidate.runtime_expr, known_depths, budget), + .comptime_branch_taken => |taken| self.iteratorConstructionDepth(taken.body, known_depths, budget), + else => 0, + }; } - fn appendExistingExprsForShape( + /// Whether some loop's first carried value is an identity-style construction + /// over one of the branch-bound locals. + fn loopConsumesBranchBoundLocal( self: *Pass, - shape: Shape, expr_id: Ast.ExprId, - out: *std.ArrayList(Ast.ExprId), + set: *std.AutoHashMap(Ast.LocalId, void), ) Allocator.Error!bool { - switch (shape) { - .any => { - try out.append(self.allocator, expr_id); - return true; + const expr = self.program.getExpr(expr_id); + switch (expr.data) { + .loop_ => |loop| { + const initials = self.program.exprSpan(loop.initial_values); + if (initials.len != 0 and self.loopInitialConsumesLocal(GuardedList.at(initials, 0), set)) return true; + return self.loopConsumesBranchBoundLocal(loop.body, set); }, - .tag => |tag| { - const expr = self.program.getExpr(expr_id); - const expr_tag = switch (expr.data) { - .tag => |expr_tag| expr_tag, - else => return false, - }; - if (!sameType(self.program, expr.ty, tag.ty) or expr_tag.name != tag.name) return false; - const payloads = self.program.exprSpan(expr_tag.payloads); - if (payloads.len != tag.payloads.len) Common.invariant("tag call pattern arity differed from tag expression arity"); - for (tag.payloads, payloads) |payload_shape, payload| { - if (!try self.appendExistingExprsForShape(payload_shape, payload, out)) return false; - } - return true; + .let_ => |let_| { + return (try self.loopConsumesBranchBoundLocal(let_.value, set)) or + (try self.loopConsumesBranchBoundLocal(let_.rest, set)); }, - .record => |record| { - const expr = self.program.getExpr(expr_id); - const fields = switch (expr.data) { - .record => |fields| self.program.fieldExprSpan(fields), - else => return false, - }; - if (!sameType(self.program, expr.ty, record.ty) or fields.len != record.fields.len) return false; - for (record.fields, fields) |field_shape, field| { - if (field_shape.name != field.name) return false; - if (!try self.appendExistingExprsForShape(field_shape.shape, field.value, out)) return false; + .block => |block| { + const statements = self.program.stmtSpan(block.statements); + for (0..statements.len) |index| { + const stmt_id = GuardedList.at(statements, index); + const found = switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| try self.loopConsumesBranchBoundLocal(let_.value, set), + .expr, .expect, .dbg => |value| try self.loopConsumesBranchBoundLocal(value, set), + .return_ => |ret| try self.loopConsumesBranchBoundLocal(ret.value, set), + else => false, + }; + if (found) return true; } - return true; + return self.loopConsumesBranchBoundLocal(block.final_expr, set); }, - .tuple => |tuple| { - const expr = self.program.getExpr(expr_id); - const items = switch (expr.data) { - .tuple => |items| self.program.exprSpan(items), - else => return false, - }; - if (!sameType(self.program, expr.ty, tuple.ty) or items.len != tuple.items.len) return false; - for (tuple.items, items) |item_shape, item| { - if (!try self.appendExistingExprsForShape(item_shape, item, out)) return false; + .if_ => |if_| { + const branches = self.program.ifBranchSpan(if_.branches); + for (0..branches.len) |index| { + if (try self.loopConsumesBranchBoundLocal(GuardedList.at(branches, index).body, set)) return true; } - return true; + return self.loopConsumesBranchBoundLocal(if_.final_else, set); }, - .nominal => |nominal| { - const expr = self.program.getExpr(expr_id); - const backing = switch (expr.data) { - .nominal => |backing| backing, - else => return false, - }; - if (!sameType(self.program, expr.ty, nominal.ty)) return false; - return try self.appendExistingExprsForShape(nominal.backing.*, backing, out); + .match_ => |match| { + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |index| { + if (try self.loopConsumesBranchBoundLocal(GuardedList.at(branches, index).body, set)) return true; + } + return false; }, - .callable => return false, + .nominal, .dbg, .expect => |child| return self.loopConsumesBranchBoundLocal(child, set), + .static_data_candidate => |candidate| return self.loopConsumesBranchBoundLocal(candidate.runtime_expr, set), + .return_ => |ret| return self.loopConsumesBranchBoundLocal(ret.value, set), + .comptime_branch_taken => |taken| return self.loopConsumesBranchBoundLocal(taken.body, set), + else => return false, } } - fn constructorShape(self: *Pass, expr_id: Ast.ExprId) Allocator.Error!?Shape { + /// Whether a loop's first initial is a single-local direct-call construction + /// (`Iter.iter(named)`) over a branch-bound local. + fn loopInitialConsumesLocal( + self: *Pass, + expr_id: Ast.ExprId, + set: *std.AutoHashMap(Ast.LocalId, void), + ) bool { const expr = self.program.getExpr(expr_id); - return switch (expr.data) { - .tag => |tag| blk: { - const payloads = self.program.exprSpan(tag.payloads); - const shapes = try self.arena.allocator().alloc(Shape, payloads.len); - for (0..payloads.len) |index| { - const payload = GuardedList.at(payloads, index); - shapes[index] = (try self.constructorShape(payload)) orelse - .{ .any = self.program.getExpr(payload).ty }; - } - break :blk Shape{ .tag = .{ - .ty = expr.ty, - .name = tag.name, - .payloads = shapes, - } }; - }, - .record => |fields_span| blk: { - const fields = self.program.fieldExprSpan(fields_span); - const shapes = try self.arena.allocator().alloc(FieldShape, fields.len); - for (0..fields.len) |index| { - const field = GuardedList.at(fields, index); - shapes[index] = .{ - .name = field.name, - .shape = (try self.constructorShape(field.value)) orelse - .{ .any = self.program.getExpr(field.value).ty }, - }; - } - break :blk Shape{ .record = .{ - .ty = expr.ty, - .fields = shapes, - } }; - }, - .tuple => |items_span| blk: { - const items = self.program.exprSpan(items_span); - const shapes = try self.arena.allocator().alloc(Shape, items.len); - for (0..items.len) |index| { - const item = GuardedList.at(items, index); - shapes[index] = (try self.constructorShape(item)) orelse - .{ .any = self.program.getExpr(item).ty }; - } - break :blk Shape{ .tuple = .{ - .ty = expr.ty, - .items = shapes, - } }; - }, - .nominal => |backing| blk: { - const backing_shape = (try self.constructorShape(backing)) orelse break :blk null; - const stored = try self.arena.allocator().create(Shape); - stored.* = backing_shape; - break :blk Shape{ .nominal = .{ - .ty = expr.ty, - .backing = stored, - } }; - }, - .fn_ref => |fn_ref| blk: { - const capture_operands = self.program.captureOperandSpan(fn_ref.captures); - const capture_shapes = try self.arena.allocator().alloc(Shape, capture_operands.len); - for (0..capture_operands.len) |index| { - const operand = GuardedList.at(capture_operands, index); - capture_shapes[index] = (try self.constructorShape(operand.value)) orelse - .{ .any = self.program.getExpr(operand.value).ty }; - } - break :blk Shape{ .callable = .{ - .ty = expr.ty, - .fn_id = fn_ref.fn_id, - .captures = capture_shapes, - } }; - }, - else => null, + if (expr.data != .call_proc) return false; + const call = expr.data.call_proc; + if (Ast.localDirectCallee(call) == null) return false; + const args = self.program.exprSpan(call.args); + if (args.len != 1) return false; + const arg = self.program.getExpr(GuardedList.at(args, 0)); + return switch (arg.data) { + .local => |local| set.contains(local), + else => false, }; } - fn shapeFromValue(self: *Pass, value: Value) Allocator.Error!?Shape { - return switch (value) { - .expr => |expr| try self.constructorShape(expr), - .tag => |tag| blk: { - const payloads = try self.arena.allocator().alloc(Shape, tag.payloads.len); - for (tag.payloads, 0..) |payload, index| { - payloads[index] = (try self.shapeFromValue(payload)) orelse - .{ .any = valueType(self.program, payload) }; - } - break :blk Shape{ .tag = .{ - .ty = tag.ty, - .name = tag.name, - .payloads = payloads, - } }; + /// The lowered desugared `for`-loop over an iterator: an iterator slot + /// plus zero or one carried accumulator, whose body pulls the next item and + /// dispatches on the pull result. Recognized structurally so the peel can + /// factor the shared base iteration out of a branch-chosen source. A + /// zero-carry loop is a side-effecting drive (optionally with an early + /// `return`, e.g. a short-circuit search); a one-carry loop is a fold whose + /// per-element result is the accumulator value the `One` arm continues with. + const IteratorLoopParts = struct { + /// The local fed to the iterator constructor in the iterator slot's + /// initial value — the branch-bound source the loop consumes. + source_local: Ast.LocalId, + /// The whole iterator-slot initial expression (a construction over + /// `source_local`), reused to build the base iteration. + iter_init: Ast.ExprId, + /// Number of carried accumulators (0 or 1). + carry_count: usize, + /// The accumulator loop parameter (valid when `carry_count == 1`). + carry_param: Ast.LocalId, + /// The accumulator loop parameter's type (valid when `carry_count == 1`). + carry_ty: Type.TypeId, + /// The type each per-element application produces: the accumulator type + /// for a fold, or a zero-sized unit for a side-effecting drive. + value_ty: Type.TypeId, + /// The `One(...)` payload's item pattern — bound to each pulled element. + item_pat: Ast.PatId, + /// The `One(...)` arm body, ending in a `continue` whose accumulator + /// value (when carried) is the per-element result. + one_body: Ast.ExprId, + /// The local bound by the `One(...)` payload's `rest` field. + rest_local: Ast.LocalId, + }; + + /// A branch arm's iterator source reduced to a shared base plus the finite + /// items an `append` chain adds after it, in yield order. + const ArmChain = struct { + base: Ast.LocalId, + items: []Ast.ExprId, + }; + + /// Rewrite a `for` over a branch-chosen `append`-style iterator into one + /// loop over the shared base source followed by a branch-dispatched tail + /// that replays the loop body for each appended item. The base loop is + /// scalarized by the whole-body clone that runs afterward; the tail folds + /// the same per-element computation over the taken arm's appended items, in + /// exactly the unfused pull order (base elements, then appended items in arm + /// order). Returns null (keeping the per-branch split) for any shape it + /// cannot faithfully replay. + fn peelBranchAppendBody(self: *Pass, body: Ast.ExprId) Common.LowerError!?Ast.ExprId { + const body_expr = self.program.getExpr(body); + const block = switch (body_expr.data) { + .block => |b| b, + else => { + return null; }, - .record => |record| blk: { - const fields = try self.arena.allocator().alloc(FieldShape, record.fields.len); - for (record.fields, 0..) |field, index| { - fields[index] = .{ - .name = field.name, - .shape = (try self.shapeFromValue(field.value)) orelse - .{ .any = valueType(self.program, field.value) }, + }; + const stmts = try GuardedList.dupe(self.allocator, Ast.StmtId, self.program.stmtSpan(block.statements)); + defer self.allocator.free(stmts); + + // Locate the driving loop: a statement whose value/expression is a loop. + // A one-carry loop that binds its result (a fold) rebinds that result + // through the tail; a zero-carry loop driven for effect (a search) runs + // the tail as an effect after it. + var loop_stmt_index: ?usize = null; + var loop_expr_id: Ast.ExprId = undefined; + var result_local: ?Ast.LocalId = null; + for (stmts, 0..) |stmt_id, index| { + switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| { + if (self.program.getExpr(let_.value).data != .loop_) continue; + result_local = switch (self.program.getPat(let_.pat).data) { + .bind => |local| local, + else => continue, }; + loop_stmt_index = index; + loop_expr_id = let_.value; + }, + .expr => |e| { + if (self.program.getExpr(e).data != .loop_) continue; + result_local = null; + loop_stmt_index = index; + loop_expr_id = e; + }, + else => continue, + } + if (loop_stmt_index != null) break; + } + const li = loop_stmt_index orelse { + return null; + }; + + const loop_parts = (try self.matchIteratorLoopParts(loop_expr_id)) orelse { + return null; + }; + if (localUseCountInExpr(self.program, loop_parts.source_local, body) != 1) { + return null; + } + // A fold's result feeds the block's final expression directly, so the + // transformed fold value can take its place. + if (loop_parts.carry_count == 1) { + const rl = result_local orelse { + return null; + }; + if (localExpr(self.program, block.final_expr) != rl) { + return null; + } + if (localUseCountInExpr(self.program, rl, body) != 1) { + return null; + } + } else if (result_local != null) { + return null; + } + + // Find the branch that binds the source, and confirm its arms share one + // base source reached by unwrapping append adapter state. + var collision_stmt_index: ?usize = null; + var branch_expr_id: Ast.ExprId = undefined; + for (stmts, 0..) |stmt_id, index| { + const let_ = switch (self.program.getStmt(stmt_id)) { + .let_ => |l| l, + else => continue, + }; + const bound = switch (self.program.getPat(let_.pat).data) { + .bind => |local| local, + else => continue, + }; + if (bound != loop_parts.source_local) continue; + switch (self.program.getExpr(let_.value).data) { + .if_, .match_ => {}, + else => { + return null; + }, + } + collision_stmt_index = index; + branch_expr_id = let_.value; + break; + } + const ci = collision_stmt_index orelse { + return null; + }; + + const base_local = (try self.sharedArmBase(branch_expr_id)) orelse { + return null; + }; + + // Build the loop so its iterator slot iterates the shared base. + const new_loop = (try self.buildLoopOverBase(loop_expr_id, base_local, loop_parts)) orelse { + return null; + }; + + // A fold threads the base loop's result into the tail; a search runs the + // tail for effect only. + var carry_start: ?Ast.ExprId = null; + var base_loop_stmt: Ast.StmtId = undefined; + var result_stmt: ?Ast.StmtId = null; + if (loop_parts.carry_count == 1) { + const temp = try self.program.addLocal(self.symbols.fresh(), loop_parts.carry_ty); + const temp_bind = try self.program.addPat(.{ .ty = loop_parts.carry_ty, .data = .{ .bind = temp } }); + base_loop_stmt = try self.program.addStmt(.{ .let_ = .{ .pat = temp_bind, .value = new_loop } }); + carry_start = try self.program.addExpr(.{ .ty = loop_parts.carry_ty, .data = .{ .local = temp } }); + } else { + base_loop_stmt = try self.program.addStmt(.{ .expr = new_loop }); + } + + // The tail replays the branch structure, each arm's body replaced by the + // per-element computation run over that arm's appended items. + const tail = (try self.buildTailDispatch(branch_expr_id, base_local, carry_start, loop_parts)) orelse { + return null; + }; + + if (loop_parts.carry_count == 1) { + const result_let = self.program.getStmt(stmts[li]).let_; + result_stmt = try self.program.addStmt(.{ .let_ = .{ .pat = result_let.pat, .value = tail } }); + } else { + result_stmt = try self.program.addStmt(.{ .expr = tail }); + } + + var new_stmts = std.ArrayList(Ast.StmtId).empty; + defer new_stmts.deinit(self.allocator); + for (stmts, 0..) |stmt_id, index| { + if (index == ci) continue; // the branch binding is replayed as the tail + if (index == li) { + try new_stmts.append(self.allocator, base_loop_stmt); + try new_stmts.append(self.allocator, result_stmt.?); + continue; + } + try new_stmts.append(self.allocator, stmt_id); + } + + return try self.program.addExpr(.{ .ty = body_expr.ty, .data = .{ .block = .{ + .statements = try self.program.addStmtSpan(new_stmts.items), + .final_expr = block.final_expr, + } } }); + } + + fn stripArmBlock(self: *Pass, expr_id: Ast.ExprId) Ast.ExprId { + var current = expr_id; + while (true) { + const expr = self.program.getExpr(current); + switch (expr.data) { + .block => |block| { + if (self.program.stmtSpan(block.statements).len != 0) return current; + current = block.final_expr; + }, + else => return current, + } + } + } + + /// Unwrap to a block's final expression regardless of intervening + /// statements. Used only to classify a function's shape, never to move + /// code, so discarding the statements is sound here. + fn blockFinal(self: *Pass, expr_id: Ast.ExprId) Ast.ExprId { + var current = expr_id; + while (true) { + const expr = self.program.getExpr(current); + switch (expr.data) { + .block => |block| current = block.final_expr, + else => return current, + } + } + } + + const DirectCall = struct { fn_id: Ast.FnId, args: Ast.ProgramSpanBorrow(Ast.ExprId, "expr_ids") }; + + fn asDirectCall(self: *Pass, expr_id: Ast.ExprId) ?DirectCall { + const expr = self.program.getExpr(expr_id); + if (expr.data != .call_proc) return null; + const call = expr.data.call_proc; + const fn_id = Ast.localDirectCallee(call) orelse return null; + return .{ .fn_id = fn_id, .args = self.program.exprSpan(call.args) }; + } + + /// Match the lowered desugared `for` loop shape, extracting the pieces the + /// peel threads. Returns null for any other loop. + fn matchIteratorLoopParts(self: *Pass, loop_expr_id: Ast.ExprId) Common.LowerError!?IteratorLoopParts { + const loop = self.program.getExpr(loop_expr_id).data.loop_; + const params = self.program.typedLocalSpan(loop.params); + const initials = self.program.exprSpan(loop.initial_values); + // Slot 0 is the iterator; at most one accumulator follows it. + if (params.len < 1 or params.len > 2 or params.len != initials.len) return null; + const carry_count = params.len - 1; + + const iter_param = GuardedList.at(params, 0).local; + const carry_param = if (carry_count == 1) GuardedList.at(params, 1).local else undefined; + + // The iterator slot's initial constructs the iterator from one source + // local — the branch-bound value. + const iter_call = self.asDirectCall(GuardedList.at(initials, 0)) orelse return null; + if (iter_call.args.len != 1) return null; + const source_local = localExpr(self.program, GuardedList.at(iter_call.args, 0)) orelse return null; + + const match_expr = self.program.getExpr(self.stripArmBlock(loop.body)); + if (match_expr.data != .match_) return null; + const match = match_expr.data.match_; + + // The scrutinee pulls the next item from the iterator slot. + const next_call = self.asDirectCall(match.scrutinee) orelse return null; + if (next_call.args.len != 1) return null; + if (localExpr(self.program, GuardedList.at(next_call.args, 0)) != iter_param) return null; + + var item_pat: ?Ast.PatId = null; + var one_body: Ast.ExprId = undefined; + var rest_local: Ast.LocalId = undefined; + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |branch_index| { + const branch = GuardedList.at(branches, branch_index); + if (branch.guard != null) return null; + const pat = self.program.getPat(branch.pat); + const tag = switch (pat.data) { + .tag => |t| t, + else => return null, + }; + const payloads = self.program.patSpan(tag.payloads); + if (payloads.len == 0) { + // Exhausted arm: breaks, carrying the accumulator unchanged. + const broke = self.stripArmBlock(branch.body); + const break_val = switch (self.program.getExpr(broke).data) { + .break_ => |maybe| maybe, + else => return null, + }; + if (carry_count == 0) { + if (break_val != null) return null; + } else { + const bv = break_val orelse return null; + if (localExpr(self.program, bv) != carry_param) return null; } - break :blk Shape{ .record = .{ - .ty = record.ty, - .fields = fields, - } }; - }, - .tuple => |tuple| blk: { - const items = try self.arena.allocator().alloc(Shape, tuple.items.len); - for (tuple.items, 0..) |item, index| { - items[index] = (try self.shapeFromValue(item)) orelse - .{ .any = valueType(self.program, item) }; - } - break :blk Shape{ .tuple = .{ - .ty = tuple.ty, - .items = items, - } }; - }, - .nominal => |nominal| blk: { - const backing_shape = (try self.shapeFromValue(nominal.backing.*)) orelse break :blk null; - const stored = try self.arena.allocator().create(Shape); - stored.* = backing_shape; - break :blk Shape{ .nominal = .{ - .ty = nominal.ty, - .backing = stored, - } }; + continue; + } + if (payloads.len != 1) return null; + const record_fields = switch (self.program.getPat(GuardedList.at(payloads, 0)).data) { + .record => |fields| self.program.recordDestructSpan(fields), + else => return null, + }; + const cont = (self.tailContinueValues(branch.body)) orelse return null; + if (cont.len != params.len) return null; + const cont_rest = localExpr(self.program, GuardedList.at(cont, 0)) orelse return null; + + if (record_fields.len == 1) { + // Skip arm: advances the iterator, accumulator unchanged. + if (carry_count == 1 and localExpr(self.program, GuardedList.at(cont, 1)) != carry_param) return null; + const only = GuardedList.at(record_fields, 0); + if (self.bindLocalOf(only.pattern) != cont_rest) return null; + continue; + } + if (record_fields.len != 2) return null; + // One arm: yields an item and advances; its continue carries the + // per-element accumulator result. + var this_item_pat: ?Ast.PatId = null; + var found_rest = false; + for (0..record_fields.len) |field_index| { + const field = GuardedList.at(record_fields, field_index); + if (self.bindLocalOf(field.pattern)) |bound| { + if (bound == cont_rest) { + found_rest = true; + continue; + } + } + if (this_item_pat != null) return null; + this_item_pat = field.pattern; + } + if (!found_rest or this_item_pat == null) return null; + item_pat = this_item_pat; + one_body = branch.body; + rest_local = cont_rest; + } + + const ip = item_pat orelse return null; + const carry_ty = if (carry_count == 1) GuardedList.at(params, 1).ty else undefined; + // A fold produces the accumulator type; a side-effecting drive produces + // the loop's own (unit) result type. Reuse an existing type id — the + // Monotype type store is frozen during this pass. + const value_ty = if (carry_count == 1) + carry_ty + else + self.program.getExpr(loop_expr_id).ty; + return .{ + .source_local = source_local, + .iter_init = GuardedList.at(initials, 0), + .carry_count = carry_count, + .carry_param = carry_param, + .carry_ty = carry_ty, + .value_ty = value_ty, + .item_pat = ip, + .one_body = one_body, + .rest_local = rest_local, + }; + } + + fn bindLocalOf(self: *Pass, pat_id: Ast.PatId) ?Ast.LocalId { + return switch (self.program.getPat(pat_id).data) { + .bind => |local| local, + else => null, + }; + } + + /// The values of the `continue` at the tail position of a loop-body arm, + /// or null when the arm's tail is not a plain `continue`. + fn tailContinueValues(self: *Pass, expr_id: Ast.ExprId) ?Ast.ProgramSpanBorrow(Ast.ExprId, "expr_ids") { + const expr = self.program.getExpr(expr_id); + return switch (expr.data) { + .continue_ => |cont| self.program.exprSpan(cont.values), + .block => |block| self.tailContinueValues(block.final_expr), + else => null, + }; + } + + /// The shared base local every arm of the source branch reduces to, or null + /// when the arms do not share one base under append unwrapping. + fn sharedArmBase(self: *Pass, branch_expr_id: Ast.ExprId) Common.LowerError!?Ast.LocalId { + const expr = self.program.getExpr(branch_expr_id); + var base: ?Ast.LocalId = null; + switch (expr.data) { + .if_ => |if_| { + const branches = self.program.ifBranchSpan(if_.branches); + for (0..branches.len) |branch_index| { + const br = GuardedList.at(branches, branch_index); + if (!try self.armBaseMatches(br.body, &base)) return null; + } + if (!try self.armBaseMatches(if_.final_else, &base)) return null; }, - .callable => |callable| blk: { - const captures = try self.arena.allocator().alloc(Shape, callable.captures.len); - for (callable.captures, 0..) |capture, index| { - captures[index] = (try self.shapeFromValue(capture.value)) orelse - .{ .any = valueType(self.program, capture.value) }; + .match_ => |match| { + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |branch_index| { + const br = GuardedList.at(branches, branch_index); + if (br.guard != null) return null; + if (!try self.armBaseMatches(br.body, &base)) return null; } - break :blk Shape{ .callable = .{ - .ty = callable.ty, - .fn_id = callable.fn_id, - .captures = captures, - } }; }, - }; + else => return null, + } + return base; } -}; -const Cloner = struct { - pass: *Pass, - source_fn: Ast.FnId, - pattern: CallPattern, - subst: std.AutoHashMap(Ast.LocalId, Value), - binder_subst: std.AutoHashMap(check.CheckedModule.PatternBinderId, Value), - changes: std.ArrayList(BindingChange), - inline_stack: std.ArrayList(Ast.FnId), - callable_stack: std.ArrayList(ActiveCallable), - loop_stack: std.ArrayList(LoopPattern), - inline_direct_calls: bool, - inline_direct_requires_known_arg: bool, - current_loc: SourceLoc, - current_region: Region, + fn armBaseMatches(self: *Pass, arm: Ast.ExprId, base: *?Ast.LocalId) Common.LowerError!bool { + const chain = (try self.reduceArmChain(arm)) orelse return false; + defer self.allocator.free(chain.items); + if (base.*) |existing| { + if (existing != chain.base) return false; + } else { + base.* = chain.base; + } + return true; + } - fn init(pass: *Pass, source_fn: Ast.FnId, pattern: CallPattern) Cloner { - return .{ - .pass = pass, - .source_fn = source_fn, - .pattern = pattern, - .subst = std.AutoHashMap(Ast.LocalId, Value).init(pass.allocator), - .binder_subst = std.AutoHashMap(check.CheckedModule.PatternBinderId, Value).init(pass.allocator), - .changes = .empty, - .inline_stack = .empty, - .callable_stack = .empty, - .loop_stack = .empty, - .inline_direct_calls = true, - .inline_direct_requires_known_arg = true, - .current_loc = SourceLoc.none, - .current_region = Region.zero(), + /// Reduce a branch arm's iterator source to its base local and the finite + /// list of items appended after it, in yield order. Caller owns the items. + fn reduceArmChain(self: *Pass, arm: Ast.ExprId) Common.LowerError!?ArmChain { + const stripped = self.stripArmBlock(arm); + if (localExpr(self.program, stripped)) |local| { + return .{ .base = local, .items = try self.allocator.alloc(Ast.ExprId, 0) }; + } + const call = self.asDirectCall(stripped) orelse return null; + if (call.args.len != 2) return null; + if (!self.fnIsSuffixAppend(call.fn_id)) return null; + const item = GuardedList.at(call.args, 1); + const inner = (try self.reduceArmChain(GuardedList.at(call.args, 0))) orelse return null; + defer self.allocator.free(inner.items); + const items = try self.allocator.alloc(Ast.ExprId, inner.items.len + 1); + @memcpy(items[0..inner.items.len], inner.items); + items[inner.items.len] = item; + return .{ .base = inner.base, .items = items }; + } + + /// Whether a two-argument function is a suffix `append` adapter: it builds + /// an iterator whose step, when its inner iterator is exhausted, yields one + /// held item and then finishes. Detected by that step's structure — the + /// exhausted arm of a pull over the inner iterator producing a held value — + /// not by the function's name. + fn fnIsSuffixAppend(self: *Pass, fn_id: Ast.FnId) bool { + const raw = @intFromEnum(fn_id); + if (raw >= self.program.fnCount()) return false; + const fn_ = self.program.getFnAt(raw); + if (self.program.typedLocalSpan(fn_.args).len != 2) return false; + const body = switch (fn_.body) { + .roc => |b| b, + .hosted => return false, + }; + // The body constructs the adapter through a `make`-style helper. + const make_call = self.asDirectCall(self.blockFinal(body)) orelse return false; + const make_raw = @intFromEnum(make_call.fn_id); + if (make_raw >= self.program.fnCount()) return false; + const make_body = switch (self.program.getFnAt(make_raw).body) { + .roc => |b| b, + .hosted => return false, }; + // The helper builds the iterator record with a step callable; find that + // step function reference among its arguments. + const record_call = self.asDirectCall(self.blockFinal(make_body)) orelse return false; + var step_fn: ?Ast.FnId = null; + for (0..record_call.args.len) |index| { + const arg = GuardedList.at(record_call.args, index); + switch (self.program.getExpr(arg).data) { + .fn_ref => |fn_ref| step_fn = fn_ref.fn_id, + else => {}, + } + } + const step = step_fn orelse return false; + return self.stepYieldsHeldItemWhenExhausted(step); } - fn initForRewrite(pass: *Pass) Cloner { - return .{ - .pass = pass, - .source_fn = undefined, // initForRewrite never calls buildArgs, which is the only reader. - .pattern = .{ .args = &.{} }, - .subst = std.AutoHashMap(Ast.LocalId, Value).init(pass.allocator), - .binder_subst = std.AutoHashMap(check.CheckedModule.PatternBinderId, Value).init(pass.allocator), - .changes = .empty, - .inline_stack = .empty, - .callable_stack = .empty, - .loop_stack = .empty, - .inline_direct_calls = true, - .inline_direct_requires_known_arg = false, - .current_loc = SourceLoc.none, - .current_region = Region.zero(), + /// Whether a step function's pull over its inner iterator has an exhausted + /// arm (a nullary-tag pattern) that yields a held item — the structural + /// signature of a suffix append, distinguishing it from prepend (item + /// yielded immediately) or map (item transformed from the inner element). + fn stepYieldsHeldItemWhenExhausted(self: *Pass, fn_id: Ast.FnId) bool { + const raw = @intFromEnum(fn_id); + if (raw >= self.program.fnCount()) return false; + const body = switch (self.program.getFnAt(raw).body) { + .roc => |b| b, + .hosted => return false, + }; + return self.exprHasExhaustedYield(body, 0); + } + + fn exprHasExhaustedYield(self: *Pass, expr_id: Ast.ExprId, depth: usize) bool { + if (depth > 64) return false; + const expr = self.program.getExpr(expr_id); + switch (expr.data) { + .match_ => |match| { + // A pull over the inner iterator: a match with a nullary-tag + // (exhausted) arm yielding a held item. + if (self.asDirectCall(match.scrutinee) != null) { + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |branch_index| { + const branch = GuardedList.at(branches, branch_index); + const tag = switch (self.program.getPat(branch.pat).data) { + .tag => |t| t, + else => continue, + }; + if (self.program.patSpan(tag.payloads).len != 0) continue; + if (self.armYieldsHeldItem(branch.body)) return true; + } + } + if (self.exprHasExhaustedYield(match.scrutinee, depth + 1)) return true; + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |branch_index| { + if (self.exprHasExhaustedYield(GuardedList.at(branches, branch_index).body, depth + 1)) return true; + } + return false; + }, + .if_ => |if_| { + const branches = self.program.ifBranchSpan(if_.branches); + for (0..branches.len) |branch_index| { + if (self.exprHasExhaustedYield(GuardedList.at(branches, branch_index).body, depth + 1)) return true; + } + return self.exprHasExhaustedYield(if_.final_else, depth + 1); + }, + .block => |block| { + const statements = self.program.stmtSpan(block.statements); + for (0..statements.len) |stmt_index| { + const stmt_id = GuardedList.at(statements, stmt_index); + switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| if (self.exprHasExhaustedYield(let_.value, depth + 1)) return true, + else => {}, + } + } + return self.exprHasExhaustedYield(block.final_expr, depth + 1); + }, + .static_data_candidate => |candidate| return self.exprHasExhaustedYield(candidate.runtime_expr, depth + 1), + .let_ => |let_| return self.exprHasExhaustedYield(let_.value, depth + 1) or + self.exprHasExhaustedYield(let_.rest, depth + 1), + else => return false, + } + } + + /// Whether an exhausted-arm body yields a `One`-style item whose held value + /// is a plain (captured) local — the appended item. + fn armYieldsHeldItem(self: *Pass, expr_id: Ast.ExprId) bool { + const yielded = self.stripArmBlock(expr_id); + const tag = switch (self.program.getExpr(yielded).data) { + .tag => |t| t, + else => return false, + }; + const payloads = self.program.exprSpan(tag.payloads); + if (payloads.len != 1) return false; + const fields = switch (self.program.getExpr(GuardedList.at(payloads, 0)).data) { + .record => |f| self.program.fieldExprSpan(f), + else => return false, }; + for (0..fields.len) |index| { + if (localExpr(self.program, GuardedList.at(fields, index).value) != null) return true; + } + return false; + } + + /// Build the loop so its iterator slot iterates the shared base, keeping + /// the accumulator slot and body unchanged. + fn buildLoopOverBase( + self: *Pass, + loop_expr_id: Ast.ExprId, + base_local: Ast.LocalId, + loop_parts: IteratorLoopParts, + ) Common.LowerError!?Ast.ExprId { + const loop_expr = self.program.getExpr(loop_expr_id); + const loop = loop_expr.data.loop_; + const iter_call_expr = self.program.getExpr(loop_parts.iter_init); + const iter_call = iter_call_expr.data.call_proc; + + const base_ty = self.program.getLocal(base_local).ty; + const base_ref = try self.program.addExpr(.{ .ty = base_ty, .data = .{ .local = base_local } }); + const new_iter_init = try self.program.addExpr(.{ .ty = iter_call_expr.ty, .data = .{ .call_proc = .{ + .callee = iter_call.callee, + .args = try self.program.addExprSpan(&.{base_ref}), + .captures = iter_call.captures, + .is_cold = iter_call.is_cold, + } } }); + + // Keep every accumulator slot's initial value; only the iterator slot + // changes to iterate the shared base. + const initials = try GuardedList.dupe(self.allocator, Ast.ExprId, self.program.exprSpan(loop.initial_values)); + defer self.allocator.free(initials); + initials[0] = new_iter_init; + return try self.program.addExpr(.{ .ty = loop_expr.ty, .data = .{ .loop_ = .{ + .params = loop.params, + .initial_values = try self.program.addExprSpan(initials), + .body = loop.body, + } } }); + } + + /// Build the branch-dispatched tail: the source branch's structure, each + /// arm's body replaced by the per-element computation run over that arm's + /// appended items in yield order. `carry_start` is the base loop's + /// accumulator result for a fold, or null for a side-effecting drive. + fn buildTailDispatch( + self: *Pass, + branch_expr_id: Ast.ExprId, + base_local: Ast.LocalId, + carry_start: ?Ast.ExprId, + loop_parts: IteratorLoopParts, + ) Common.LowerError!?Ast.ExprId { + const expr = self.program.getExpr(branch_expr_id); + switch (expr.data) { + .if_ => |if_| { + const branches = try GuardedList.dupe(self.allocator, Ast.IfBranch, self.program.ifBranchSpan(if_.branches)); + defer self.allocator.free(branches); + var rewritten = try self.allocator.alloc(Ast.IfBranch, branches.len); + defer self.allocator.free(rewritten); + for (branches, 0..) |br, index| { + const arm = (try self.buildArmTail(br.body, base_local, carry_start, loop_parts)) orelse return null; + rewritten[index] = .{ .cond = br.cond, .body = arm }; + } + const final_else = (try self.buildArmTail(if_.final_else, base_local, carry_start, loop_parts)) orelse return null; + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .{ .if_ = .{ + .branches = try self.program.addIfBranchSpan(rewritten), + .final_else = final_else, + } } }); + }, + .match_ => |match| { + const branches = try GuardedList.dupe(self.allocator, Ast.Branch, self.program.branchSpan(match.branches)); + defer self.allocator.free(branches); + var rewritten = try self.allocator.alloc(Ast.Branch, branches.len); + defer self.allocator.free(rewritten); + for (branches, 0..) |br, index| { + const arm = (try self.buildArmTail(br.body, base_local, carry_start, loop_parts)) orelse return null; + rewritten[index] = .{ .pat = br.pat, .guard = br.guard, .body = arm }; + } + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .{ .match_ = .{ + .scrutinee = match.scrutinee, + .branches = try self.program.addBranchSpan(rewritten), + .comptime_site = match.comptime_site, + } } }); + }, + else => return null, + } + } + + /// Run the loop's per-element computation over one arm's appended items in + /// yield order. For a fold, thread each intermediate accumulator through a + /// fresh binding starting from `carry_start`; for a drive, sequence the + /// per-item effects. An arm that appends nothing yields the incoming + /// accumulator (fold) or a no-op (drive). + fn buildArmTail( + self: *Pass, + arm: Ast.ExprId, + base_local: Ast.LocalId, + carry_start: ?Ast.ExprId, + loop_parts: IteratorLoopParts, + ) Common.LowerError!?Ast.ExprId { + const chain = (try self.reduceArmChain(arm)) orelse return null; + defer self.allocator.free(chain.items); + if (chain.base != base_local) return null; + + if (chain.items.len == 0) { + if (loop_parts.carry_count == 1) { + const start = carry_start orelse return null; + return start; + } + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .unit }); + } + + var carry_ref = carry_start; + var stmts = std.ArrayList(Ast.StmtId).empty; + defer stmts.deinit(self.allocator); + for (chain.items, 0..) |item, index| { + const step = (try self.buildBodyApplication(carry_ref, item, loop_parts)) orelse return null; + if (index + 1 == chain.items.len) { + if (stmts.items.len == 0) return step; + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .{ .block = .{ + .statements = try self.program.addStmtSpan(stmts.items), + .final_expr = step, + } } }); + } + if (loop_parts.carry_count == 1) { + const fresh = try self.program.addLocal(self.symbols.fresh(), loop_parts.carry_ty); + const bind = try self.program.addPat(.{ .ty = loop_parts.carry_ty, .data = .{ .bind = fresh } }); + try stmts.append(self.allocator, try self.program.addStmt(.{ .let_ = .{ .pat = bind, .value = step } })); + carry_ref = try self.program.addExpr(.{ .ty = loop_parts.carry_ty, .data = .{ .local = fresh } }); + } else { + try stmts.append(self.allocator, try self.program.addStmt(.{ .expr = step })); + } + } + unreachable; + } + + /// One application of the loop body: bind the item pattern to an appended + /// item (and, for a fold, the accumulator parameter to the incoming + /// accumulator), then run the per-element computation to its result. Every + /// bound local is renamed fresh so the tail's applications and the base loop + /// stay independent. + fn buildBodyApplication( + self: *Pass, + carry_expr: ?Ast.ExprId, + item_expr: Ast.ExprId, + loop_parts: IteratorLoopParts, + ) Common.LowerError!?Ast.ExprId { + var renames = std.AutoHashMap(Ast.LocalId, Ast.LocalId).init(self.allocator); + defer renames.deinit(); + + // Guard against the accumulator flowing through the dropped iterator + // slot: the rest binding must be read only by the continue we drop. + if (localUseCountInExpr(self.program, loop_parts.rest_local, loop_parts.one_body) != 1) return null; + + var stmts = std.ArrayList(Ast.StmtId).empty; + defer stmts.deinit(self.allocator); + + const item_pat = (try self.clonePatFresh(loop_parts.item_pat, &renames)) orelse return null; + try stmts.append(self.allocator, try self.program.addStmt(.{ .let_ = .{ .pat = item_pat, .value = item_expr } })); + + if (loop_parts.carry_count == 1) { + const carry = carry_expr orelse return null; + const carry_local = try self.program.addLocal(self.symbols.fresh(), loop_parts.carry_ty); + try renames.put(loop_parts.carry_param, carry_local); + const carry_bind = try self.program.addPat(.{ .ty = loop_parts.carry_ty, .data = .{ .bind = carry_local } }); + try stmts.append(self.allocator, try self.program.addStmt(.{ .let_ = .{ .pat = carry_bind, .value = carry } })); + } + + const body = (try self.cloneNewCarry(loop_parts.one_body, &renames, loop_parts)) orelse return null; + + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .{ .block = .{ + .statements = try self.program.addStmtSpan(stmts.items), + .final_expr = body, + } } }); + } + + /// Deep-clone a loop-body arm with all bound locals renamed fresh, + /// replacing the tail `continue` with its per-element result: the + /// accumulator value for a fold, or a unit for a side-effecting drive. + /// Early `return`s are preserved (they exit the enclosing function the same + /// way in the peeled tail). Returns null for constructs outside the + /// foldable set (a nested loop, a `break`, a lambda), keeping the peel from + /// duplicating unsupported control flow. + fn cloneNewCarry( + self: *Pass, + expr_id: Ast.ExprId, + renames: *std.AutoHashMap(Ast.LocalId, Ast.LocalId), + loop_parts: IteratorLoopParts, + ) Common.LowerError!?Ast.ExprId { + const expr = self.program.getExpr(expr_id); + switch (expr.data) { + .continue_ => |cont| { + const values = self.program.exprSpan(cont.values); + if (values.len != loop_parts.carry_count + 1) return null; + if (loop_parts.carry_count == 0) { + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .unit }); + } + return try self.cloneExprFresh(GuardedList.at(values, 1), renames); + }, + .block => |block| { + const source = try GuardedList.dupe(self.allocator, Ast.StmtId, self.program.stmtSpan(block.statements)); + defer self.allocator.free(source); + var stmts = std.ArrayList(Ast.StmtId).empty; + defer stmts.deinit(self.allocator); + for (source) |stmt_id| { + const cloned = (try self.cloneStmtFresh(stmt_id, renames)) orelse return null; + try stmts.append(self.allocator, cloned); + } + const final = (try self.cloneNewCarry(block.final_expr, renames, loop_parts)) orelse return null; + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .{ .block = .{ + .statements = try self.program.addStmtSpan(stmts.items), + .final_expr = final, + } } }); + }, + .if_ => |if_| { + const branches = try GuardedList.dupe(self.allocator, Ast.IfBranch, self.program.ifBranchSpan(if_.branches)); + defer self.allocator.free(branches); + var rewritten = try self.allocator.alloc(Ast.IfBranch, branches.len); + defer self.allocator.free(rewritten); + for (branches, 0..) |br, index| { + const cond = (try self.cloneExprFresh(br.cond, renames)) orelse return null; + const arm = (try self.cloneNewCarry(br.body, renames, loop_parts)) orelse return null; + rewritten[index] = .{ .cond = cond, .body = arm }; + } + const final_else = (try self.cloneNewCarry(if_.final_else, renames, loop_parts)) orelse return null; + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .{ .if_ = .{ + .branches = try self.program.addIfBranchSpan(rewritten), + .final_else = final_else, + } } }); + }, + .match_ => |match| { + const scrutinee = (try self.cloneExprFresh(match.scrutinee, renames)) orelse return null; + const branches = try GuardedList.dupe(self.allocator, Ast.Branch, self.program.branchSpan(match.branches)); + defer self.allocator.free(branches); + var rewritten = try self.allocator.alloc(Ast.Branch, branches.len); + defer self.allocator.free(rewritten); + for (branches, 0..) |br, index| { + if (br.guard != null) return null; + const pat = (try self.clonePatFresh(br.pat, renames)) orelse return null; + const arm = (try self.cloneNewCarry(br.body, renames, loop_parts)) orelse return null; + rewritten[index] = .{ .pat = pat, .guard = null, .body = arm }; + } + return try self.program.addExpr(.{ .ty = loop_parts.value_ty, .data = .{ .match_ = .{ + .scrutinee = scrutinee, + .branches = try self.program.addBranchSpan(rewritten), + .comptime_site = match.comptime_site, + } } }); + }, + else => return try self.cloneExprFresh(expr_id, renames), + } + } + + fn cloneStmtFresh(self: *Pass, stmt_id: Ast.StmtId, renames: *std.AutoHashMap(Ast.LocalId, Ast.LocalId)) Common.LowerError!?Ast.StmtId { + switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| { + const value = (try self.cloneExprFresh(let_.value, renames)) orelse return null; + const pat = (try self.clonePatFresh(let_.pat, renames)) orelse return null; + return try self.program.addStmt(.{ .let_ = .{ + .pat = pat, + .value = value, + .recursive = let_.recursive, + .comptime_site = let_.comptime_site, + } }); + }, + .expr => |e| { + const cloned = (try self.cloneExprFresh(e, renames)) orelse return null; + return try self.program.addStmt(.{ .expr = cloned }); + }, + else => return null, + } + } + + /// Deep-clone a pure-computation expression, applying local renames and + /// allocating fresh locals at binding sites. Returns null for constructs + /// outside the foldable set. + fn cloneExprFresh(self: *Pass, expr_id: Ast.ExprId, renames: *std.AutoHashMap(Ast.LocalId, Ast.LocalId)) Common.LowerError!?Ast.ExprId { + const expr = self.program.getExpr(expr_id); + const data: Ast.ExprData = switch (expr.data) { + .local => |local| .{ .local = renames.get(local) orelse local }, + .unit => .unit, + .int_lit => |v| .{ .int_lit = v }, + .frac_f32_lit => |v| .{ .frac_f32_lit = v }, + .frac_f64_lit => |v| .{ .frac_f64_lit = v }, + .dec_lit => |v| .{ .dec_lit = v }, + .str_lit => |v| .{ .str_lit = v }, + .bytes_lit => |v| .{ .bytes_lit = v }, + .crash => |v| .{ .crash = v }, + .list => |items| .{ .list = (try self.cloneExprSpanFresh(items, renames)) orelse return null }, + .tuple => |items| .{ .tuple = (try self.cloneExprSpanFresh(items, renames)) orelse return null }, + .record => |fields| .{ .record = (try self.cloneFieldSpanFresh(fields, renames)) orelse return null }, + .tag => |tag| .{ .tag = .{ + .name = tag.name, + .payloads = (try self.cloneExprSpanFresh(tag.payloads, renames)) orelse return null, + } }, + .static_data_candidate => |candidate| .{ .static_data_candidate = .{ + .static_data = candidate.static_data, + .runtime_expr = (try self.cloneExprFresh(candidate.runtime_expr, renames)) orelse return null, + } }, + .nominal => |backing| .{ .nominal = (try self.cloneExprFresh(backing, renames)) orelse return null }, + .fn_ref => |fn_ref| .{ .fn_ref = .{ + .fn_id = fn_ref.fn_id, + .captures = (try self.cloneCaptureOperandSpanFresh(fn_ref.captures, renames)) orelse return null, + } }, + .field_access => |field| .{ .field_access = .{ + .receiver = (try self.cloneExprFresh(field.receiver, renames)) orelse return null, + .field = field.field, + } }, + .tuple_access => |access| .{ .tuple_access = .{ + .tuple = (try self.cloneExprFresh(access.tuple, renames)) orelse return null, + .elem_index = access.elem_index, + } }, + .structural_eq => |eq| .{ .structural_eq = .{ + .lhs = (try self.cloneExprFresh(eq.lhs, renames)) orelse return null, + .rhs = (try self.cloneExprFresh(eq.rhs, renames)) orelse return null, + .negated = eq.negated, + } }, + .structural_hash => |h| .{ .structural_hash = .{ + .value = (try self.cloneExprFresh(h.value, renames)) orelse return null, + .hasher = (try self.cloneExprFresh(h.hasher, renames)) orelse return null, + } }, + .low_level => |call| .{ .low_level = .{ + .op = call.op, + .args = (try self.cloneExprSpanFresh(call.args, renames)) orelse return null, + } }, + .call_proc => |call| .{ .call_proc = .{ + .callee = call.callee, + .args = (try self.cloneExprSpanFresh(call.args, renames)) orelse return null, + .captures = (try self.cloneCaptureOperandSpanFresh(call.captures, renames)) orelse return null, + .is_cold = call.is_cold, + } }, + .call_value => |call| .{ .call_value = .{ + .callee = (try self.cloneExprFresh(call.callee, renames)) orelse return null, + .args = (try self.cloneExprSpanFresh(call.args, renames)) orelse return null, + } }, + .let_ => |let_| blk: { + const value = (try self.cloneExprFresh(let_.value, renames)) orelse return null; + const pat = (try self.clonePatFresh(let_.bind, renames)) orelse return null; + const rest = (try self.cloneExprFresh(let_.rest, renames)) orelse return null; + break :blk .{ .let_ = .{ + .bind = pat, + .value = value, + .rest = rest, + .comptime_site = let_.comptime_site, + } }; + }, + .block => |block| blk: { + const source = try GuardedList.dupe(self.allocator, Ast.StmtId, self.program.stmtSpan(block.statements)); + defer self.allocator.free(source); + var stmts = std.ArrayList(Ast.StmtId).empty; + defer stmts.deinit(self.allocator); + for (source) |stmt_id| { + const cloned = (try self.cloneStmtFresh(stmt_id, renames)) orelse return null; + try stmts.append(self.allocator, cloned); + } + const final = (try self.cloneExprFresh(block.final_expr, renames)) orelse return null; + break :blk .{ .block = .{ + .statements = try self.program.addStmtSpan(stmts.items), + .final_expr = final, + } }; + }, + .if_ => |if_| blk: { + const branches = try GuardedList.dupe(self.allocator, Ast.IfBranch, self.program.ifBranchSpan(if_.branches)); + defer self.allocator.free(branches); + var rewritten = try self.allocator.alloc(Ast.IfBranch, branches.len); + defer self.allocator.free(rewritten); + for (branches, 0..) |br, index| { + const cond = (try self.cloneExprFresh(br.cond, renames)) orelse return null; + const arm = (try self.cloneExprFresh(br.body, renames)) orelse return null; + rewritten[index] = .{ .cond = cond, .body = arm }; + } + const final_else = (try self.cloneExprFresh(if_.final_else, renames)) orelse return null; + break :blk .{ .if_ = .{ + .branches = try self.program.addIfBranchSpan(rewritten), + .final_else = final_else, + } }; + }, + .match_ => |match| blk: { + const scrutinee = (try self.cloneExprFresh(match.scrutinee, renames)) orelse return null; + const branches = try GuardedList.dupe(self.allocator, Ast.Branch, self.program.branchSpan(match.branches)); + defer self.allocator.free(branches); + var rewritten = try self.allocator.alloc(Ast.Branch, branches.len); + defer self.allocator.free(rewritten); + for (branches, 0..) |br, index| { + if (br.guard != null) return null; + const pat = (try self.clonePatFresh(br.pat, renames)) orelse return null; + const arm = (try self.cloneExprFresh(br.body, renames)) orelse return null; + rewritten[index] = .{ .pat = pat, .guard = null, .body = arm }; + } + break :blk .{ .match_ = .{ + .scrutinee = scrutinee, + .branches = try self.program.addBranchSpan(rewritten), + .comptime_site = match.comptime_site, + } }; + }, + // An early return exits the enclosing function; it is preserved + // verbatim in the peeled tail, where it fires only after the base + // iteration completes without returning — the same order the + // unfused loop would return in. + .return_ => |ret| .{ .return_ = .{ + .value = (try self.cloneExprFresh(ret.value, renames)) orelse return null, + .target = ret.target, + } }, + else => return null, + }; + return try self.program.addExpr(.{ .ty = expr.ty, .data = data }); + } + + fn cloneExprSpanFresh(self: *Pass, span: Ast.Span(Ast.ExprId), renames: *std.AutoHashMap(Ast.LocalId, Ast.LocalId)) Common.LowerError!?Ast.Span(Ast.ExprId) { + const source = try GuardedList.dupe(self.allocator, Ast.ExprId, self.program.exprSpan(span)); + defer self.allocator.free(source); + var out = try self.allocator.alloc(Ast.ExprId, source.len); + defer self.allocator.free(out); + for (source, 0..) |item, index| { + out[index] = (try self.cloneExprFresh(item, renames)) orelse return null; + } + return try self.program.addExprSpan(out); + } + + fn cloneCaptureOperandSpanFresh(self: *Pass, span: Ast.Span(Ast.CaptureOperand), renames: *std.AutoHashMap(Ast.LocalId, Ast.LocalId)) Common.LowerError!?Ast.Span(Ast.CaptureOperand) { + const source = try GuardedList.dupe(self.allocator, Ast.CaptureOperand, self.program.captureOperandSpan(span)); + defer self.allocator.free(source); + var out = try self.allocator.alloc(Ast.CaptureOperand, source.len); + defer self.allocator.free(out); + for (source, 0..) |operand, index| { + out[index] = .{ + .id = operand.id, + .value = (try self.cloneExprFresh(operand.value, renames)) orelse return null, + }; + } + return try self.program.addCaptureOperandSpan(out); + } + + fn cloneFieldSpanFresh(self: *Pass, span: Ast.Span(Ast.FieldExpr), renames: *std.AutoHashMap(Ast.LocalId, Ast.LocalId)) Common.LowerError!?Ast.Span(Ast.FieldExpr) { + const source = try GuardedList.dupe(self.allocator, Ast.FieldExpr, self.program.fieldExprSpan(span)); + defer self.allocator.free(source); + var out = try self.allocator.alloc(Ast.FieldExpr, source.len); + defer self.allocator.free(out); + for (source, 0..) |field, index| { + out[index] = .{ + .name = field.name, + .value = (try self.cloneExprFresh(field.value, renames)) orelse return null, + }; + } + return try self.program.addFieldExprSpan(out); + } + + /// Clone a pattern, allocating a fresh local for every binding site and + /// recording the rename. Returns null for list/string patterns, which the + /// fold does not replay. + fn clonePatFresh(self: *Pass, pat_id: Ast.PatId, renames: *std.AutoHashMap(Ast.LocalId, Ast.LocalId)) Common.LowerError!?Ast.PatId { + const pat = self.program.getPat(pat_id); + const data: Ast.PatData = switch (pat.data) { + .bind => |local| blk: { + const fresh = try self.program.addLocal(self.symbols.fresh(), pat.ty); + try renames.put(local, fresh); + break :blk .{ .bind = fresh }; + }, + .wildcard => .wildcard, + .int_lit => |v| .{ .int_lit = v }, + .dec_lit => |v| .{ .dec_lit = v }, + .frac_f32_lit => |v| .{ .frac_f32_lit = v }, + .frac_f64_lit => |v| .{ .frac_f64_lit = v }, + .str_lit => |v| .{ .str_lit = v }, + .as => |as| blk: { + const inner = (try self.clonePatFresh(as.pattern, renames)) orelse return null; + const fresh = try self.program.addLocal(self.symbols.fresh(), pat.ty); + try renames.put(as.local, fresh); + break :blk .{ .as = .{ .pattern = inner, .local = fresh } }; + }, + .record => |fields_span| blk: { + const fields = try GuardedList.dupe(self.allocator, Ast.RecordDestruct, self.program.recordDestructSpan(fields_span)); + defer self.allocator.free(fields); + var out = try self.allocator.alloc(Ast.RecordDestruct, fields.len); + defer self.allocator.free(out); + for (fields, 0..) |field, index| { + out[index] = .{ + .name = field.name, + .pattern = (try self.clonePatFresh(field.pattern, renames)) orelse return null, + }; + } + break :blk .{ .record = try self.program.addRecordDestructSpan(out) }; + }, + .tuple => |items_span| blk: { + const cloned = (try self.clonePatSpanFresh(items_span, renames)) orelse return null; + break :blk .{ .tuple = cloned }; + }, + .tag => |tag| blk: { + const cloned = (try self.clonePatSpanFresh(tag.payloads, renames)) orelse return null; + break :blk .{ .tag = .{ .name = tag.name, .payloads = cloned } }; + }, + .nominal => |backing| .{ .nominal = (try self.clonePatFresh(backing, renames)) orelse return null }, + else => return null, + }; + return try self.program.addPat(.{ .ty = pat.ty, .data = data }); + } + + fn clonePatSpanFresh(self: *Pass, span: Ast.Span(Ast.PatId), renames: *std.AutoHashMap(Ast.LocalId, Ast.LocalId)) Common.LowerError!?Ast.Span(Ast.PatId) { + const source = try GuardedList.dupe(self.allocator, Ast.PatId, self.program.patSpan(span)); + defer self.allocator.free(source); + var out = try self.allocator.alloc(Ast.PatId, source.len); + defer self.allocator.free(out); + for (source, 0..) |child, index| { + out[index] = (try self.clonePatFresh(child, renames)) orelse return null; + } + return try self.program.addPatSpan(out); + } + + /// Clone a whole function body through the value pass and replace it. The + /// value pass inlines the branch-chosen iterator's construction into each + /// branch, sinks the consuming loop into those branches, and scalarizes each + /// sunk loop's carried state. + fn cloneFnBodyInPlace(self: *Pass, fn_id: Ast.FnId, body: Ast.ExprId) Common.LowerError!void { + var cloner = Cloner.initForRewrite(self); + defer cloner.deinit(); + // The branch-chosen iterator's construction chain (its source `List.iter`, + // each `append`/`map` adapter) spans separate `let` bindings. Its leaf + // source is a list value the known-shape gate does not count, so the + // source construction would stay residual and the branch would never + // become a known value the loop can sink into. Counting a list source as + // a known-shape argument exposes that construction — enough for the branch + // to sink and each sunk loop to scalarize — without force-inlining the + // arbitrary user calls whose over-inlining breaks known-match collapse. + cloner.inline_direct_requires_known_arg = true; + cloner.inline_list_source_construction = true; + cloner.force_loop_initial_inline = true; + const cloned = try cloner.cloneExpr(body); + const fn_ = self.program.getFn(fn_id); + self.program.setFn(fn_id, .{ + .symbol = fn_.symbol, + .source = fn_.source, + .args = fn_.args, + .captures = fn_.captures, + .body = .{ .roc = cloned }, + .ret = fn_.ret, + }); + } + + /// Collect the outermost loops whose first carried value is built by a + /// direct call — the shape a `for` over an iterator lowers to. A nested loop + /// is left to the clone of its enclosing loop, and a plain counting loop + /// (scalars initialized by literals) does not qualify. + fn collectIteratorLoops(self: *Pass, expr_id: Ast.ExprId, out: *std.ArrayList(Ast.ExprId)) Allocator.Error!void { + const expr = self.program.getExpr(expr_id); + switch (expr.data) { + .loop_ => |loop| { + const initials = self.program.exprSpan(loop.initial_values); + if (initials.len != 0 and self.loopInitialIsOwnedConstruction(GuardedList.at(initials, 0))) { + try out.append(self.allocator, expr_id); + return; + } + try self.collectIteratorLoops(loop.body, out); + }, + .let_ => |let_| { + try self.collectIteratorLoops(let_.value, out); + try self.collectIteratorLoops(let_.rest, out); + }, + .block => |block| { + const statements = self.program.stmtSpan(block.statements); + for (0..statements.len) |stmt_index| { + const stmt_id = GuardedList.at(statements, stmt_index); + switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| try self.collectIteratorLoops(let_.value, out), + .expr, .expect, .dbg => |value| try self.collectIteratorLoops(value, out), + .return_ => |ret| try self.collectIteratorLoops(ret.value, out), + else => {}, + } + } + try self.collectIteratorLoops(block.final_expr, out); + }, + .match_ => |match| { + try self.collectIteratorLoops(match.scrutinee, out); + const branches = self.program.branchSpan(match.branches); + for (0..branches.len) |branch_index| { + try self.collectIteratorLoops(GuardedList.at(branches, branch_index).body, out); + } + }, + .if_ => |if_| { + const branches = self.program.ifBranchSpan(if_.branches); + for (0..branches.len) |branch_index| { + try self.collectIteratorLoops(GuardedList.at(branches, branch_index).body, out); + } + try self.collectIteratorLoops(if_.final_else, out); + }, + .nominal, .dbg, .expect => |child| try self.collectIteratorLoops(child, out), + .static_data_candidate => |candidate| try self.collectIteratorLoops(candidate.runtime_expr, out), + .return_ => |ret| try self.collectIteratorLoops(ret.value, out), + .comptime_branch_taken => |taken| try self.collectIteratorLoops(taken.body, out), + else => {}, + } + } + + /// Whether a loop's first carried value is an iterator built directly over a + /// source the loop owns — a construction call (`List.iter`, a range) whose + /// arguments are all built inline rather than named locals. A `for` over a + /// pipeline named beforehand (`for x in some_iter`) reads its source through + /// a local, so it does not qualify here; a branch-chosen source instead + /// routes through the whole-body clone, which sinks the loop into each + /// branch where the source construction is inline. + fn loopInitialIsOwnedConstruction(self: *Pass, expr_id: Ast.ExprId) bool { + const expr = self.program.getExpr(expr_id); + if (expr.data != .call_proc) return false; + const call = expr.data.call_proc; + if (Ast.localDirectCallee(call) == null) return false; + const args = self.program.exprSpan(call.args); + for (0..args.len) |index| { + const arg = GuardedList.at(args, index); + if (self.program.getExpr(arg).data == .local) return false; + } + return true; + } + + /// Clone one loop through the value pass and overwrite the original in + /// place. Free locals the loop reads (its enclosing bindings) resolve to + /// themselves, so only the loop-carried iterator construction inlines. + fn cloneLoopInPlace(self: *Pass, loop_id: Ast.ExprId) Common.LowerError!void { + var cloner = Cloner.initForRewrite(self); + defer cloner.deinit(); + cloner.inline_direct_requires_known_arg = true; + cloner.force_loop_initial_inline = true; + const cloned = try cloner.cloneExpr(loop_id); + self.program.setExprData(loop_id, self.program.getExpr(cloned).data); + } + + fn rewriteCallsInExpr(self: *Pass, expr_id: Ast.ExprId, done: []bool) Allocator.Error!void { + const index = @intFromEnum(expr_id); + if (done[index]) return; + done[index] = true; + + const expr = self.program.getExprAt(index); + switch (expr.data) { + .local, + .unit, + .int_lit, + .frac_f32_lit, + .frac_f64_lit, + .dec_lit, + .str_lit, + .bytes_lit, + .crash, + .comptime_exhaustiveness_failed, + .uninitialized, + .uninitialized_payload, + => {}, + .fn_ref => |fn_ref| try self.rewriteCallsInCaptureOperandSpan(fn_ref.captures, done), + .list, + .tuple, + => |items| try self.rewriteCallsInExprSpan(items, done), + .record => |fields| try self.rewriteCallsInFieldExprSpan(fields, done), + .tag => |tag| try self.rewriteCallsInExprSpan(tag.payloads, done), + .static_data_candidate => |candidate| try self.rewriteCallsInExpr(candidate.runtime_expr, done), + .nominal, + .dbg, + .expect, + => |child| try self.rewriteCallsInExpr(child, done), + .return_ => |ret| try self.rewriteCallsInExpr(ret.value, done), + .expect_err => |expect_err| try self.rewriteCallsInExpr(expect_err.msg, done), + .comptime_branch_taken => |taken| try self.rewriteCallsInExpr(taken.body, done), + .let_ => |let_| { + try self.rewriteCallsInExpr(let_.value, done); + try self.rewriteCallsInExpr(let_.rest, done); + }, + .lambda, + .def_ref, + .fn_def, + => Common.invariant("pre-lift function expression reached call-pattern specialization"), + .call_value => |call| { + try self.rewriteCallsInExpr(call.callee, done); + try self.rewriteCallsInExprSpan(call.args, done); + }, + .call_proc => |call| { + try self.rewriteCallsInExprSpan(call.args, done); + try self.rewriteCallsInCaptureOperandSpan(call.captures, done); + try self.rewriteCallProc(expr_id, call); + }, + .low_level => |call| try self.rewriteCallsInExprSpan(call.args, done), + .field_access => |field| try self.rewriteCallsInExpr(field.receiver, done), + .tuple_access => |access| try self.rewriteCallsInExpr(access.tuple, done), + .structural_eq => |eq| { + try self.rewriteCallsInExpr(eq.lhs, done); + try self.rewriteCallsInExpr(eq.rhs, done); + }, + .structural_hash => |h| { + try self.rewriteCallsInExpr(h.value, done); + try self.rewriteCallsInExpr(h.hasher, done); + }, + .match_ => |match| { + try self.rewriteCallsInExpr(match.scrutinee, done); + try self.rewriteCallsInBranchSpan(match.branches, done); + }, + .if_ => |if_| { + try self.rewriteCallsInIfBranchSpan(if_.branches, done); + try self.rewriteCallsInExpr(if_.final_else, done); + }, + .block => |block| { + try self.rewriteCallsInStmtSpan(block.statements, done); + try self.rewriteCallsInExpr(block.final_expr, done); + }, + .loop_ => |loop| { + try self.rewriteCallsInExprSpan(loop.initial_values, done); + try self.rewriteCallsInExpr(loop.body, done); + }, + .break_ => |maybe| if (maybe) |value| try self.rewriteCallsInExpr(value, done), + .continue_ => |continue_| try self.rewriteCallsInExprSpan(continue_.values, done), + .if_initialized_payload => |payload_switch| { + try self.rewriteCallsInExpr(payload_switch.cond, done); + try self.rewriteCallsInExpr(payload_switch.initialized, done); + try self.rewriteCallsInExpr(payload_switch.uninitialized, done); + }, + .try_sequence => |sequence| { + try self.rewriteCallsInExpr(sequence.try_expr, done); + try self.rewriteCallsInExpr(sequence.ok_body, done); + }, + .try_record_sequence => |sequence| { + try self.rewriteCallsInExpr(sequence.try_expr, done); + try self.rewriteCallsInExpr(sequence.ok_body, done); + }, + } + } + + fn rewriteCallsInExprSpan(self: *Pass, span: Ast.Span(Ast.ExprId), done: []bool) Allocator.Error!void { + const source = try GuardedList.dupe(self.allocator, Ast.ExprId, self.program.exprSpan(span)); + defer self.allocator.free(source); + for (source) |expr| try self.rewriteCallsInExpr(expr, done); + } + + fn rewriteCallsInCaptureOperandSpan(self: *Pass, span: Ast.Span(Ast.CaptureOperand), done: []bool) Allocator.Error!void { + const source = try GuardedList.dupe(self.allocator, Ast.CaptureOperand, self.program.captureOperandSpan(span)); + defer self.allocator.free(source); + for (source) |operand| try self.rewriteCallsInExpr(operand.value, done); + } + + fn rewriteCallsInFieldExprSpan(self: *Pass, span: Ast.Span(Ast.FieldExpr), done: []bool) Allocator.Error!void { + const source = try GuardedList.dupe(self.allocator, Ast.FieldExpr, self.program.fieldExprSpan(span)); + defer self.allocator.free(source); + for (source) |field| try self.rewriteCallsInExpr(field.value, done); + } + + fn rewriteCallsInBranchSpan(self: *Pass, span: Ast.Span(Ast.Branch), done: []bool) Allocator.Error!void { + const source = try GuardedList.dupe(self.allocator, Ast.Branch, self.program.branchSpan(span)); + defer self.allocator.free(source); + for (source) |branch| { + if (branch.guard) |guard| try self.rewriteCallsInExpr(guard, done); + try self.rewriteCallsInExpr(branch.body, done); + } + } + + fn rewriteCallsInIfBranchSpan(self: *Pass, span: Ast.Span(Ast.IfBranch), done: []bool) Allocator.Error!void { + const source = try GuardedList.dupe(self.allocator, Ast.IfBranch, self.program.ifBranchSpan(span)); + defer self.allocator.free(source); + for (source) |branch| { + try self.rewriteCallsInExpr(branch.cond, done); + try self.rewriteCallsInExpr(branch.body, done); + } + } + + fn rewriteCallsInStmtSpan(self: *Pass, span: Ast.Span(Ast.StmtId), done: []bool) Allocator.Error!void { + const source = try GuardedList.dupe(self.allocator, Ast.StmtId, self.program.stmtSpan(span)); + defer self.allocator.free(source); + for (source) |stmt| try self.rewriteCallsInStmt(stmt, done); + } + + fn rewriteCallsInStmt(self: *Pass, stmt_id: Ast.StmtId, done: []bool) Allocator.Error!void { + switch (self.program.getStmt(stmt_id)) { + .let_ => |let_| try self.rewriteCallsInExpr(let_.value, done), + .expr, + .expect, + .dbg, + => |expr| try self.rewriteCallsInExpr(expr, done), + .return_ => |ret| try self.rewriteCallsInExpr(ret.value, done), + .uninitialized, .crash => {}, + } + } + + fn rewriteCallProc(self: *Pass, expr_id: Ast.ExprId, call: @import("../monotype/ast.zig").CallProc) Allocator.Error!void { + const callee = Ast.localDirectCallee(call) orelse return; + const raw = @intFromEnum(callee); + if (raw >= self.plans.len) return; + if (self.plans[raw].specs.items.len == 0) return; + + const args = try GuardedList.dupe(self.allocator, Ast.ExprId, self.program.exprSpan(call.args)); + defer self.allocator.free(args); + for (self.plans[raw].specs.items) |spec| { + var rewritten_args = std.ArrayList(Ast.ExprId).empty; + defer rewritten_args.deinit(self.allocator); + + var cloner = Cloner.initForRewrite(self); + defer cloner.deinit(); + + if (try self.appendExistingCallArgs(&cloner, spec.pattern, args, &rewritten_args)) { + const new_call: Ast.ExprData = .{ .call_proc = .{ + .callee = .{ .lifted = spec.fn_id orelse Common.invariant("call-pattern specialization id was not assigned before rewriting") }, + .args = try self.program.addExprSpan(rewritten_args.items), + .captures = call.captures, + .is_cold = call.is_cold, + } }; + if (cloner.pending.items.len == 0) { + self.program.setExprData(expr_id, new_call); + } else { + // Decomposing the argument created bindings its leaves + // reference; the rewritten call site becomes a let chain + // ending in the specialized call. + const call_ty = self.program.getExpr(expr_id).ty; + const call_expr = try cloner.addExpr(.{ .ty = call_ty, .data = new_call }); + const wrapped = try cloner.flushPendingSince(0, call_expr); + self.program.setExprData(expr_id, self.program.getExpr(wrapped).data); + } + return; + } + } + } + + fn appendExistingCallArgs( + self: *Pass, + cloner: *Cloner, + pattern: CallPattern, + args: []const Ast.ExprId, + out: *std.ArrayList(Ast.ExprId), + ) Allocator.Error!bool { + if (pattern.args.len != args.len) Common.invariant("call-pattern arity differed from direct call arity"); + for (pattern.args, args) |shape, arg| { + const value = try cloner.cloneExprValue(arg); + if (!shapeMatchesValue(self.program, shape, value)) return false; + try cloner.appendExprsFromValue(shape, value, out); + } + return true; + } + + fn appendExistingExprsForShape( + self: *Pass, + shape: Shape, + expr_id: Ast.ExprId, + out: *std.ArrayList(Ast.ExprId), + ) Allocator.Error!bool { + switch (shape) { + .any => { + try out.append(self.allocator, expr_id); + return true; + }, + .tag => |tag| { + const expr = self.program.getExpr(expr_id); + const expr_tag = switch (expr.data) { + .tag => |expr_tag| expr_tag, + else => return false, + }; + if (!sameType(self.program, expr.ty, tag.ty) or !self.program.names.tagLabelTextEql(expr_tag.name, tag.name)) return false; + const payloads = self.program.exprSpan(expr_tag.payloads); + if (payloads.len != tag.payloads.len) Common.invariant("tag call pattern arity differed from tag expression arity"); + for (tag.payloads, payloads) |payload_shape, payload| { + if (!try self.appendExistingExprsForShape(payload_shape, payload, out)) return false; + } + return true; + }, + .record => |record| { + const expr = self.program.getExpr(expr_id); + const fields = switch (expr.data) { + .record => |fields| self.program.fieldExprSpan(fields), + else => return false, + }; + if (!sameType(self.program, expr.ty, record.ty) or fields.len != record.fields.len) return false; + for (record.fields, fields) |field_shape, field| { + if (!self.program.names.recordFieldLabelTextEql(field_shape.name, field.name)) return false; + if (!try self.appendExistingExprsForShape(field_shape.shape, field.value, out)) return false; + } + return true; + }, + .tuple => |tuple| { + const expr = self.program.getExpr(expr_id); + const items = switch (expr.data) { + .tuple => |items| self.program.exprSpan(items), + else => return false, + }; + if (!sameType(self.program, expr.ty, tuple.ty) or items.len != tuple.items.len) return false; + for (tuple.items, items) |item_shape, item| { + if (!try self.appendExistingExprsForShape(item_shape, item, out)) return false; + } + return true; + }, + .nominal => |nominal| { + const expr = self.program.getExpr(expr_id); + const backing = switch (expr.data) { + .nominal => |backing| backing, + else => return false, + }; + if (!sameType(self.program, expr.ty, nominal.ty)) return false; + return try self.appendExistingExprsForShape(nominal.backing.*, backing, out); + }, + .callable => return false, + } + } + + fn constructorShape(self: *Pass, expr_id: Ast.ExprId) Allocator.Error!?Shape { + const expr = self.program.getExpr(expr_id); + return switch (expr.data) { + .tag => |tag| blk: { + const payloads = self.program.exprSpan(tag.payloads); + const shapes = try self.arena.allocator().alloc(Shape, payloads.len); + for (0..payloads.len) |index| { + const payload = GuardedList.at(payloads, index); + shapes[index] = (try self.constructorShape(payload)) orelse + .{ .any = self.program.getExpr(payload).ty }; + } + break :blk Shape{ .tag = .{ + .ty = expr.ty, + .name = tag.name, + .payloads = shapes, + } }; + }, + .record => |fields_span| blk: { + const fields = self.program.fieldExprSpan(fields_span); + const shapes = try self.arena.allocator().alloc(FieldShape, fields.len); + for (0..fields.len) |index| { + const field = GuardedList.at(fields, index); + shapes[index] = .{ + .name = field.name, + .shape = (try self.constructorShape(field.value)) orelse + .{ .any = self.program.getExpr(field.value).ty }, + }; + } + break :blk Shape{ .record = .{ + .ty = expr.ty, + .fields = shapes, + } }; + }, + .tuple => |items_span| blk: { + const items = self.program.exprSpan(items_span); + const shapes = try self.arena.allocator().alloc(Shape, items.len); + for (0..items.len) |index| { + const item = GuardedList.at(items, index); + shapes[index] = (try self.constructorShape(item)) orelse + .{ .any = self.program.getExpr(item).ty }; + } + break :blk Shape{ .tuple = .{ + .ty = expr.ty, + .items = shapes, + } }; + }, + .nominal => |backing| blk: { + const backing_shape = (try self.constructorShape(backing)) orelse break :blk null; + const stored = try self.arena.allocator().create(Shape); + stored.* = backing_shape; + break :blk Shape{ .nominal = .{ + .ty = expr.ty, + .backing = stored, + } }; + }, + .fn_ref => |fn_ref| blk: { + const capture_operands = self.program.captureOperandSpan(fn_ref.captures); + const capture_shapes = try self.arena.allocator().alloc(Shape, capture_operands.len); + for (0..capture_operands.len) |index| { + const operand = GuardedList.at(capture_operands, index); + capture_shapes[index] = (try self.constructorShape(operand.value)) orelse + .{ .any = self.program.getExpr(operand.value).ty }; + } + break :blk Shape{ .callable = .{ + .ty = expr.ty, + .fn_id = fn_ref.fn_id, + .captures = capture_shapes, + } }; + }, + else => null, + }; + } + + /// Total work budget for deriving one shape. Values reachable here are + /// not always small finite trees — a loop-carried value can reference + /// itself through the fixpoint of a recursive construction, and deep + /// chains share substructure — so the walk spends one shared budget per + /// node visit and degrades to `.any` (no known shape) when it runs out. + /// `.any` is this function's existing "don't specialize on this" answer, + /// so exhaustion is a missed specialization, never a wrong shape. See + /// design.md "Core Principles" on bounded post-check walks. + const shape_work_budget: u32 = 4096; + + fn shapeFromValue(self: *Pass, value: Value) Allocator.Error!?Shape { + var budget: u32 = shape_work_budget; + return try self.shapeFromValueBudgeted(value, &budget); + } + + fn shapeFromValueBudgeted(self: *Pass, value: Value, budget: *u32) Allocator.Error!?Shape { + if (budget.* == 0) return null; + budget.* -= 1; + return switch (value) { + .expr => |expr| try self.constructorShape(expr), + .static_data_candidate => |candidate| try self.shapeFromValueBudgeted(candidate.runtime.*, budget), + .tag => |tag| blk: { + const payloads = try self.arena.allocator().alloc(Shape, tag.payloads.len); + for (tag.payloads, 0..) |payload, index| { + payloads[index] = (try self.shapeFromValueBudgeted(payload, budget)) orelse + .{ .any = valueType(self.program, payload) }; + } + break :blk Shape{ .tag = .{ + .ty = tag.ty, + .name = tag.name, + .payloads = payloads, + } }; + }, + .record => |record| blk: { + const fields = try self.arena.allocator().alloc(FieldShape, record.fields.len); + for (record.fields, 0..) |field, index| { + fields[index] = .{ + .name = field.name, + .shape = (try self.shapeFromValueBudgeted(field.value, budget)) orelse + .{ .any = valueType(self.program, field.value) }, + }; + } + break :blk Shape{ .record = .{ + .ty = record.ty, + .fields = fields, + } }; + }, + .tuple => |tuple| blk: { + const items = try self.arena.allocator().alloc(Shape, tuple.items.len); + for (tuple.items, 0..) |item, index| { + items[index] = (try self.shapeFromValueBudgeted(item, budget)) orelse + .{ .any = valueType(self.program, item) }; + } + break :blk Shape{ .tuple = .{ + .ty = tuple.ty, + .items = items, + } }; + }, + .nominal => |nominal| blk: { + const backing_shape = (try self.shapeFromValueBudgeted(nominal.backing.*, budget)) orelse break :blk null; + const stored = try self.arena.allocator().create(Shape); + stored.* = backing_shape; + break :blk Shape{ .nominal = .{ + .ty = nominal.ty, + .backing = stored, + } }; + }, + .callable => |callable| blk: { + const captures = try self.arena.allocator().alloc(Shape, callable.captures.len); + for (callable.captures, 0..) |capture, index| { + captures[index] = (try self.shapeFromValueBudgeted(capture.value, budget)) orelse + .{ .any = valueType(self.program, capture.value) }; + } + break :blk Shape{ .callable = .{ + .ty = callable.ty, + .fn_id = callable.fn_id, + .captures = captures, + } }; + }, + }; + } +}; + +const Cloner = struct { + pass: *Pass, + source_fn: Ast.FnId, + pattern: CallPattern, + subst: std.AutoHashMap(Ast.LocalId, Value), + binder_subst: std.AutoHashMap(BinderIdentity, Value), + changes: std.ArrayList(BindingChange), + inline_stack: std.ArrayList(InlineFrame), + callable_stack: std.ArrayList(ActiveCallable), + loop_stack: std.ArrayList(LoopPattern), + /// Bindings created while producing a structured value, not yet emitted. + /// Each holds a fresh local the value's leaves reference. They are + /// emitted — oldest outermost, preserving evaluation order — at the + /// nearest enclosing region boundary (`cloneExpr`), or earlier by any + /// construct that pins its value with `resolvePending`. + pending: std.ArrayList(PendingLet), + /// Count of effect-bearing expressions emitted so far. Compared against + /// `region_entry_marks` to decide whether a pending binding may move to + /// its region's start without crossing an effect. + effect_marks: usize, + region_entry_marks: usize, + inline_direct_calls: bool, + inline_direct_requires_known_arg: bool, + rewrite_call_patterns: bool, + value_aware_rewrite_changed: bool, + value_aware_detect_only: bool, + /// When set, a loop's initial values inline their construction call even + /// without a known-shape argument, exposing an iterator constructor whose + /// arguments (a source list, a range bound) are opaque scalars. Only set for + /// an in-place loop clone, where the surrounding bindings are absent, so a + /// named upstream pipeline stays a residual value rather than being expanded + /// here (which the branch-join/`append` value tracking is not yet ready for). + force_loop_initial_inline: bool = false, + /// When set, a `list` (or `str`) source expression counts as a known-shape + /// argument, so a direct construction over it (`List.iter(list)`) inlines + /// even under the known-shape gate. Set only for a branch-chosen loop's + /// whole-body clone, where the iterator source must inline for the branch to + /// become a known value the loop can sink into. + inline_list_source_construction: bool = false, + current_loc: SourceLoc, + current_region: Region, + + fn init(pass: *Pass, source_fn: Ast.FnId, pattern: CallPattern) Cloner { + return .{ + .pass = pass, + .source_fn = source_fn, + .pattern = pattern, + .subst = std.AutoHashMap(Ast.LocalId, Value).init(pass.allocator), + .binder_subst = std.AutoHashMap(BinderIdentity, Value).init(pass.allocator), + .changes = .empty, + .inline_stack = .empty, + .callable_stack = .empty, + .loop_stack = .empty, + .pending = .empty, + .effect_marks = 0, + .region_entry_marks = 0, + .inline_direct_calls = true, + .inline_direct_requires_known_arg = true, + .rewrite_call_patterns = true, + .value_aware_rewrite_changed = false, + .value_aware_detect_only = false, + .current_loc = SourceLoc.none, + .current_region = Region.zero(), + }; + } + + fn initForRewrite(pass: *Pass) Cloner { + return .{ + .pass = pass, + .source_fn = undefined, // initForRewrite never calls buildArgs, which is the only reader. + .pattern = .{ .args = &.{} }, + .subst = std.AutoHashMap(Ast.LocalId, Value).init(pass.allocator), + .binder_subst = std.AutoHashMap(BinderIdentity, Value).init(pass.allocator), + .changes = .empty, + .inline_stack = .empty, + .callable_stack = .empty, + .loop_stack = .empty, + .pending = .empty, + .effect_marks = 0, + .region_entry_marks = 0, + .inline_direct_calls = true, + .inline_direct_requires_known_arg = false, + .rewrite_call_patterns = true, + .value_aware_rewrite_changed = false, + .value_aware_detect_only = false, + .current_loc = SourceLoc.none, + .current_region = Region.zero(), + }; + } + + fn deinit(self: *Cloner) void { + self.pending.deinit(self.pass.allocator); + self.inline_stack.deinit(self.pass.allocator); + self.callable_stack.deinit(self.pass.allocator); + self.loop_stack.deinit(self.pass.allocator); + self.changes.deinit(self.pass.allocator); + self.binder_subst.deinit(); + self.subst.deinit(); + } + + fn collectCallPatternsInExpr(self: *Cloner, owner: Ast.FnId, expr_id: Ast.ExprId) Common.LowerError!void { + const expr = self.pass.program.getExpr(expr_id); + switch (expr.data) { + .local, + .unit, + .int_lit, + .frac_f32_lit, + .frac_f64_lit, + .dec_lit, + .str_lit, + .bytes_lit, + .crash, + .comptime_exhaustiveness_failed, + .uninitialized, + .uninitialized_payload, + => {}, + .fn_ref => |fn_ref| try self.collectCallPatternsInCaptureOperandSpan(owner, fn_ref.captures), + .list, + .tuple, + => |items| try self.collectCallPatternsInExprSpan(owner, items), + .record => |fields| try self.collectCallPatternsInFieldExprSpan(owner, fields), + .tag => |tag| try self.collectCallPatternsInExprSpan(owner, tag.payloads), + .static_data_candidate => |candidate| try self.collectCallPatternsInExpr(owner, candidate.runtime_expr), + .nominal, + .dbg, + .expect, + => |child| try self.collectCallPatternsInExpr(owner, child), + .return_ => |ret| try self.collectCallPatternsInExpr(owner, ret.value), + .expect_err => |expect_err| try self.collectCallPatternsInExpr(owner, expect_err.msg), + .comptime_branch_taken => |taken| try self.collectCallPatternsInExpr(owner, taken.body), + .let_ => |let_| try self.collectCallPatternsInLet(owner, let_.bind, let_.value, let_.rest, false), + .lambda, + .def_ref, + .fn_def, + => Common.invariant("pre-lift function expression reached call-pattern specialization"), + .call_value => |call| { + try self.collectCallPatternsInExpr(owner, call.callee); + try self.collectCallPatternsInExprSpan(owner, call.args); + }, + .call_proc => |call| { + try self.collectCallPatternsInExprSpan(owner, call.args); + try self.collectCallPatternsInCaptureOperandSpan(owner, call.captures); + + const callee = Ast.localDirectCallee(call) orelse return; + const callee_raw = @intFromEnum(callee); + if (callee_raw >= self.pass.self_recursive_fns.len or !self.pass.self_recursive_fns[callee_raw]) return; + const args = try GuardedList.dupe(self.pass.allocator, Ast.ExprId, self.pass.program.exprSpan(call.args)); + defer self.pass.allocator.free(args); + + const pending_start = self.pending.items.len; + defer self.pending.shrinkRetainingCapacity(pending_start); + + const values = try self.pass.allocator.alloc(Value, args.len); + defer self.pass.allocator.free(values); + for (args, 0..) |arg, index| { + values[index] = try self.cloneExprValue(arg); + } + try self.pass.recordCallPatternForValues(callee, values); + }, + .low_level => |call| try self.collectCallPatternsInExprSpan(owner, call.args), + .field_access => |field| try self.collectCallPatternsInExpr(owner, field.receiver), + .tuple_access => |access| try self.collectCallPatternsInExpr(owner, access.tuple), + .structural_eq => |eq| { + try self.collectCallPatternsInExpr(owner, eq.lhs); + try self.collectCallPatternsInExpr(owner, eq.rhs); + }, + .structural_hash => |h| { + try self.collectCallPatternsInExpr(owner, h.value); + try self.collectCallPatternsInExpr(owner, h.hasher); + }, + .match_ => |match| { + try self.collectCallPatternsInExpr(owner, match.scrutinee); + try self.collectCallPatternsInBranchSpan(owner, match.branches); + }, + .if_ => |if_| { + try self.collectCallPatternsInIfBranchSpan(owner, if_.branches); + try self.collectCallPatternsInExpr(owner, if_.final_else); + }, + .block => |block| { + const change_start = self.changes.items.len; + const pending_start = self.pending.items.len; + defer { + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_start); + } + try self.collectCallPatternsInStmtSpan(owner, block.statements); + try self.collectCallPatternsInExpr(owner, block.final_expr); + }, + .loop_ => |loop| { + try self.collectCallPatternsInExprSpan(owner, loop.initial_values); + const change_start = self.changes.items.len; + defer self.restore(change_start); + const params = self.pass.program.typedLocalSpan(loop.params); + for (0..params.len) |index| { + try self.shadowLocal(GuardedList.at(params, index).local); + } + try self.collectCallPatternsInExpr(owner, loop.body); + }, + .break_ => |maybe| if (maybe) |value| try self.collectCallPatternsInExpr(owner, value), + .continue_ => |continue_| try self.collectCallPatternsInExprSpan(owner, continue_.values), + .if_initialized_payload => |payload_switch| { + try self.collectCallPatternsInExpr(owner, payload_switch.cond); + try self.collectCallPatternsInExpr(owner, payload_switch.initialized); + try self.collectCallPatternsInExpr(owner, payload_switch.uninitialized); + }, + .try_sequence => |sequence| { + try self.collectCallPatternsInExpr(owner, sequence.try_expr); + const change_start = self.changes.items.len; + defer self.restore(change_start); + try self.shadowLocal(sequence.ok_local); + try self.collectCallPatternsInExpr(owner, sequence.ok_body); + }, + .try_record_sequence => |sequence| { + try self.collectCallPatternsInExpr(owner, sequence.try_expr); + const change_start = self.changes.items.len; + defer self.restore(change_start); + try self.shadowLocal(sequence.value_local); + try self.shadowLocal(sequence.rest_local); + try self.collectCallPatternsInExpr(owner, sequence.ok_body); + }, + } + } + + fn collectCallPatternsInLet( + self: *Cloner, + owner: Ast.FnId, + pat_id: Ast.PatId, + value_expr: Ast.ExprId, + rest_expr: Ast.ExprId, + recursive: bool, + ) Common.LowerError!void { + try self.collectCallPatternsInExpr(owner, value_expr); + + const change_start = self.changes.items.len; + const pending_start = self.pending.items.len; + defer { + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_start); + } + + const value = try self.cloneExprValue(value_expr); + if (!try self.bindPatternForValueFlow(pat_id, value_expr, recursive, value)) { + try self.shadowPatLocals(pat_id); + } + try self.collectCallPatternsInExpr(owner, rest_expr); + } + + fn collectCallPatternsInExprSpan(self: *Cloner, owner: Ast.FnId, span: Ast.Span(Ast.ExprId)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.ExprId, self.pass.program.exprSpan(span)); + defer self.pass.allocator.free(source); + for (source) |expr| try self.collectCallPatternsInExpr(owner, expr); + } + + fn collectCallPatternsInCaptureOperandSpan(self: *Cloner, owner: Ast.FnId, span: Ast.Span(Ast.CaptureOperand)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.CaptureOperand, self.pass.program.captureOperandSpan(span)); + defer self.pass.allocator.free(source); + for (source) |operand| try self.collectCallPatternsInExpr(owner, operand.value); + } + + fn collectCallPatternsInFieldExprSpan(self: *Cloner, owner: Ast.FnId, span: Ast.Span(Ast.FieldExpr)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.FieldExpr, self.pass.program.fieldExprSpan(span)); + defer self.pass.allocator.free(source); + for (source) |field| try self.collectCallPatternsInExpr(owner, field.value); + } + + fn collectCallPatternsInBranchSpan(self: *Cloner, owner: Ast.FnId, span: Ast.Span(Ast.Branch)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.Branch, self.pass.program.branchSpan(span)); + defer self.pass.allocator.free(source); + for (source) |branch| { + const change_start = self.changes.items.len; + defer self.restore(change_start); + try self.shadowPatLocals(branch.pat); + if (branch.guard) |guard| try self.collectCallPatternsInExpr(owner, guard); + try self.collectCallPatternsInExpr(owner, branch.body); + } + } + + fn collectCallPatternsInIfBranchSpan(self: *Cloner, owner: Ast.FnId, span: Ast.Span(Ast.IfBranch)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.IfBranch, self.pass.program.ifBranchSpan(span)); + defer self.pass.allocator.free(source); + for (source) |branch| { + try self.collectCallPatternsInExpr(owner, branch.cond); + try self.collectCallPatternsInExpr(owner, branch.body); + } + } + + fn collectCallPatternsInStmtSpan(self: *Cloner, owner: Ast.FnId, span: Ast.Span(Ast.StmtId)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.StmtId, self.pass.program.stmtSpan(span)); + defer self.pass.allocator.free(source); + for (source) |stmt| try self.collectCallPatternsInStmt(owner, stmt); + } + + fn collectCallPatternsInStmt(self: *Cloner, owner: Ast.FnId, stmt_id: Ast.StmtId) Common.LowerError!void { + switch (self.pass.program.getStmt(stmt_id)) { + .let_ => |let_| { + try self.collectCallPatternsInExpr(owner, let_.value); + + const pending_start = self.pending.items.len; + defer self.pending.shrinkRetainingCapacity(pending_start); + + const value = try self.cloneExprValue(let_.value); + if (!try self.bindPatternForValueFlow(let_.pat, let_.value, let_.recursive, value)) { + try self.shadowPatLocals(let_.pat); + } + }, + .expr, + .expect, + .dbg, + => |expr| try self.collectCallPatternsInExpr(owner, expr), + .return_ => |ret| try self.collectCallPatternsInExpr(owner, ret.value), + .uninitialized => |pat| try self.shadowPatLocals(pat), + .crash => {}, + } + } + + fn bindPatternForValueFlow( + self: *Cloner, + pat_id: Ast.PatId, + source_value: Ast.ExprId, + recursive: bool, + value: Value, + ) Common.LowerError!bool { + const change_before = self.changes.items.len; + const pending_before = self.pending.items.len; + if (try self.bindPatToReusableValue(pat_id, value)) return true; + self.restore(change_before); + self.pending.shrinkRetainingCapacity(pending_before); + + const pat = self.pass.program.getPat(pat_id); + const self_referential = switch (pat.data) { + .bind => |local| localUseCountInExpr(self.pass.program, local, source_value) != 0, + else => recursive, + }; + if (self_referential) return false; + + const reusable = try self.makeReusableForMatch(value); + if (try self.bindPatToValue(pat_id, reusable)) return true; + self.restore(change_before); + self.pending.shrinkRetainingCapacity(pending_before); + return false; + } + + fn rewriteCallsWithValuesInExpr(self: *Cloner, expr_id: Ast.ExprId) Common.LowerError!void { + const expr = self.pass.program.getExpr(expr_id); + switch (expr.data) { + .local, + .unit, + .int_lit, + .frac_f32_lit, + .frac_f64_lit, + .dec_lit, + .str_lit, + .bytes_lit, + .crash, + .comptime_exhaustiveness_failed, + .uninitialized, + .uninitialized_payload, + => {}, + .fn_ref => |fn_ref| try self.rewriteCallsWithValuesInCaptureOperandSpan(fn_ref.captures), + .list, + .tuple, + => |items| try self.rewriteCallsWithValuesInExprSpan(items), + .record => |fields| try self.rewriteCallsWithValuesInFieldExprSpan(fields), + .tag => |tag| try self.rewriteCallsWithValuesInExprSpan(tag.payloads), + .static_data_candidate => |candidate| try self.rewriteCallsWithValuesInExpr(candidate.runtime_expr), + .nominal, + .dbg, + .expect, + => |child| try self.rewriteCallsWithValuesInExpr(child), + .return_ => |ret| try self.rewriteCallsWithValuesInExpr(ret.value), + .expect_err => |expect_err| try self.rewriteCallsWithValuesInExpr(expect_err.msg), + .comptime_branch_taken => |taken| try self.rewriteCallsWithValuesInExpr(taken.body), + .let_ => |let_| { + try self.rewriteCallsWithValuesInExpr(let_.value); + const change_start = self.changes.items.len; + const pending_start = self.pending.items.len; + defer { + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_start); + } + const value = try self.cloneExprValue(let_.value); + if (!try self.bindPatternForValueFlow(let_.bind, let_.value, false, value)) { + try self.shadowPatLocals(let_.bind); + } + try self.rewriteCallsWithValuesInExpr(let_.rest); + }, + .lambda, + .def_ref, + .fn_def, + => Common.invariant("pre-lift function expression reached call-pattern specialization"), + .call_value => |call| { + try self.rewriteCallsWithValuesInExpr(call.callee); + try self.rewriteCallsWithValuesInExprSpan(call.args); + }, + .call_proc => |call| { + try self.rewriteCallsWithValuesInExprSpan(call.args); + try self.rewriteCallsWithValuesInCaptureOperandSpan(call.captures); + try self.rewriteCallProcWithValues(expr_id, call); + }, + .low_level => |call| try self.rewriteCallsWithValuesInExprSpan(call.args), + .field_access => |field| try self.rewriteCallsWithValuesInExpr(field.receiver), + .tuple_access => |access| try self.rewriteCallsWithValuesInExpr(access.tuple), + .structural_eq => |eq| { + try self.rewriteCallsWithValuesInExpr(eq.lhs); + try self.rewriteCallsWithValuesInExpr(eq.rhs); + }, + .structural_hash => |h| try self.rewriteCallsWithValuesInExpr(h.value), + .match_ => |match| { + try self.rewriteCallsWithValuesInExpr(match.scrutinee); + try self.rewriteCallsWithValuesInBranchSpan(match.branches); + }, + .if_ => |if_| { + try self.rewriteCallsWithValuesInIfBranchSpan(if_.branches); + try self.rewriteCallsWithValuesInExpr(if_.final_else); + }, + .block => |block| { + const change_start = self.changes.items.len; + const pending_start = self.pending.items.len; + defer { + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_start); + } + try self.rewriteCallsWithValuesInStmtSpan(block.statements); + try self.rewriteCallsWithValuesInExpr(block.final_expr); + }, + .loop_ => |loop| { + try self.rewriteCallsWithValuesInExprSpan(loop.initial_values); + const change_start = self.changes.items.len; + defer self.restore(change_start); + const params = self.pass.program.typedLocalSpan(loop.params); + for (0..params.len) |index| { + try self.shadowLocal(GuardedList.at(params, index).local); + } + try self.rewriteCallsWithValuesInExpr(loop.body); + }, + .break_ => |maybe| if (maybe) |value| try self.rewriteCallsWithValuesInExpr(value), + .continue_ => |continue_| try self.rewriteCallsWithValuesInExprSpan(continue_.values), + .if_initialized_payload => |payload_switch| { + try self.rewriteCallsWithValuesInExpr(payload_switch.cond); + try self.rewriteCallsWithValuesInExpr(payload_switch.initialized); + try self.rewriteCallsWithValuesInExpr(payload_switch.uninitialized); + }, + .try_sequence => |sequence| { + try self.rewriteCallsWithValuesInExpr(sequence.try_expr); + const change_start = self.changes.items.len; + defer self.restore(change_start); + try self.shadowLocal(sequence.ok_local); + try self.rewriteCallsWithValuesInExpr(sequence.ok_body); + }, + .try_record_sequence => |sequence| { + try self.rewriteCallsWithValuesInExpr(sequence.try_expr); + const change_start = self.changes.items.len; + defer self.restore(change_start); + try self.shadowLocal(sequence.value_local); + try self.shadowLocal(sequence.rest_local); + try self.rewriteCallsWithValuesInExpr(sequence.ok_body); + }, + } + } + + fn rewriteCallProcWithValues(self: *Cloner, expr_id: Ast.ExprId, call: @import("../monotype/ast.zig").CallProc) Common.LowerError!void { + if (call.is_cold) return; + const callee = Ast.localDirectCallee(call) orelse return; + const raw = @intFromEnum(callee); + if (raw >= self.pass.plans.len or self.pass.plans[raw].specs.items.len == 0) return; + if (raw >= self.pass.self_recursive_fns.len or !self.pass.self_recursive_fns[raw]) return; + + const args = try GuardedList.dupe(self.pass.allocator, Ast.ExprId, self.pass.program.exprSpan(call.args)); + defer self.pass.allocator.free(args); + + const pending_start = self.pending.items.len; + defer self.pending.shrinkRetainingCapacity(pending_start); + + const values = try self.pass.allocator.alloc(Value, args.len); + defer self.pass.allocator.free(values); + for (args, 0..) |arg, index| { + values[index] = try self.cloneExprValue(arg); + } + + for (self.pass.plans[raw].specs.items) |spec| { + if (spec.pattern.args.len != values.len) Common.invariant("call-pattern arity differed from direct call arity"); + var matches = true; + for (spec.pattern.args, values) |shape, value| { + if (!shapeMatchesValue(self.pass.program, shape, value)) { + matches = false; + break; + } + } + if (!matches) continue; + + self.value_aware_rewrite_changed = true; + if (self.value_aware_detect_only) return; + + var rewritten_args = std.ArrayList(Ast.ExprId).empty; + defer rewritten_args.deinit(self.pass.allocator); + for (spec.pattern.args, values) |shape, value| { + try self.appendExprsFromValue(shape, value, &rewritten_args); + } + + const new_call: Ast.ExprData = .{ .call_proc = .{ + .callee = .{ .lifted = spec.fn_id orelse Common.invariant("call-pattern specialization id was not assigned before value-aware rewriting") }, + .args = try self.pass.program.addExprSpan(rewritten_args.items), + .captures = call.captures, + .is_cold = call.is_cold, + } }; + if (self.pending.items.len == pending_start) { + self.pass.program.setExprData(expr_id, new_call); + } else { + const call_ty = self.pass.program.getExpr(expr_id).ty; + const call_expr = try self.addExpr(.{ .ty = call_ty, .data = new_call }); + const wrapped = try self.flushPendingSince(pending_start, call_expr); + self.pass.program.setExprData(expr_id, self.pass.program.getExpr(wrapped).data); + } + return; + } + } + + fn rewriteCallsWithValuesInExprSpan(self: *Cloner, span: Ast.Span(Ast.ExprId)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.ExprId, self.pass.program.exprSpan(span)); + defer self.pass.allocator.free(source); + for (source) |expr| try self.rewriteCallsWithValuesInExpr(expr); + } + + fn rewriteCallsWithValuesInCaptureOperandSpan(self: *Cloner, span: Ast.Span(Ast.CaptureOperand)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.CaptureOperand, self.pass.program.captureOperandSpan(span)); + defer self.pass.allocator.free(source); + for (source) |operand| try self.rewriteCallsWithValuesInExpr(operand.value); + } + + fn rewriteCallsWithValuesInFieldExprSpan(self: *Cloner, span: Ast.Span(Ast.FieldExpr)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.FieldExpr, self.pass.program.fieldExprSpan(span)); + defer self.pass.allocator.free(source); + for (source) |field| try self.rewriteCallsWithValuesInExpr(field.value); + } + + fn rewriteCallsWithValuesInBranchSpan(self: *Cloner, span: Ast.Span(Ast.Branch)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.Branch, self.pass.program.branchSpan(span)); + defer self.pass.allocator.free(source); + for (source) |branch| { + const change_start = self.changes.items.len; + defer self.restore(change_start); + try self.shadowPatLocals(branch.pat); + if (branch.guard) |guard| try self.rewriteCallsWithValuesInExpr(guard); + try self.rewriteCallsWithValuesInExpr(branch.body); + } + } + + fn rewriteCallsWithValuesInIfBranchSpan(self: *Cloner, span: Ast.Span(Ast.IfBranch)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.IfBranch, self.pass.program.ifBranchSpan(span)); + defer self.pass.allocator.free(source); + for (source) |branch| { + try self.rewriteCallsWithValuesInExpr(branch.cond); + try self.rewriteCallsWithValuesInExpr(branch.body); + } + } + + fn rewriteCallsWithValuesInStmtSpan(self: *Cloner, span: Ast.Span(Ast.StmtId)) Common.LowerError!void { + const source = try GuardedList.dupe(self.pass.allocator, Ast.StmtId, self.pass.program.stmtSpan(span)); + defer self.pass.allocator.free(source); + for (source) |stmt| try self.rewriteCallsWithValuesInStmt(stmt); } - fn deinit(self: *Cloner) void { - self.inline_stack.deinit(self.pass.allocator); - self.callable_stack.deinit(self.pass.allocator); - self.loop_stack.deinit(self.pass.allocator); - self.changes.deinit(self.pass.allocator); - self.binder_subst.deinit(); - self.subst.deinit(); + fn rewriteCallsWithValuesInStmt(self: *Cloner, stmt_id: Ast.StmtId) Common.LowerError!void { + switch (self.pass.program.getStmt(stmt_id)) { + .let_ => |let_| { + try self.rewriteCallsWithValuesInExpr(let_.value); + + const pending_start = self.pending.items.len; + defer self.pending.shrinkRetainingCapacity(pending_start); + + const value = try self.cloneExprValue(let_.value); + if (!try self.bindPatternForValueFlow(let_.pat, let_.value, let_.recursive, value)) { + try self.shadowPatLocals(let_.pat); + } + }, + .expr, + .expect, + .dbg, + => |expr| try self.rewriteCallsWithValuesInExpr(expr), + .return_ => |ret| try self.rewriteCallsWithValuesInExpr(ret.value), + .uninitialized => |pat| try self.shadowPatLocals(pat), + .crash => {}, + } } fn buildArgs(self: *Cloner) Allocator.Error!Ast.Span(Ast.TypedLocal) { @@ -1501,7 +3819,15 @@ const Cloner = struct { if (expr_loc.hasLocation()) self.current_loc = expr_loc; const expr_region = self.pass.program.exprRegion(expr_id); if (!expr_region.isEmpty()) self.current_region = expr_region; - return try self.materialize(try self.cloneExprValue(expr_id)); + + // Region boundary: pending bindings created below this expression are + // emitted here, where they dominate every leaf reference inside it. + const pending_start = self.pending.items.len; + const saved_entry_marks = self.region_entry_marks; + self.region_entry_marks = self.effect_marks; + defer self.region_entry_marks = saved_entry_marks; + const result = try self.materialize(try self.cloneExprValue(expr_id)); + return try self.flushPendingSince(pending_start, result); } fn cloneExprValue(self: *Cloner, expr_id: Ast.ExprId) Common.LowerError!Value { @@ -1518,12 +3844,21 @@ const Cloner = struct { switch (expr.data) { .local => |local| { if (self.subst.get(local)) |value| return value; - if (self.pass.program.getLocal(local).binder) |binder| { - if (self.binder_subst.get(binder)) |value| return value; + if (self.binderIdentityOf(local)) |identity| { + if (self.binder_subst.get(identity)) |value| return value; } return .{ .expr = try self.addExpr(.{ .ty = expr.ty, .data = .{ .local = local } }) }; }, .fn_ref => |fn_ref| return try self.callableValueFromRef(expr.ty, fn_ref), + .static_data_candidate => |candidate| { + const runtime = try self.pass.arena.allocator().create(Value); + runtime.* = try self.cloneExprValue(candidate.runtime_expr); + return .{ .static_data_candidate = .{ + .ty = expr.ty, + .static_data = candidate.static_data, + .runtime = runtime, + } }; + }, .tag => |tag| { const payload_exprs = try GuardedList.dupe(self.pass.allocator, Ast.ExprId, self.pass.program.exprSpan(tag.payloads)); defer self.pass.allocator.free(payload_exprs); @@ -1572,9 +3907,14 @@ const Cloner = struct { } }; }, .let_ => |let_| return try self.cloneLetValue(let_), + .loop_ => |loop| return try self.cloneLoopValue(expr.ty, loop), + .block => |block| { + if (try self.cloneBlockValue(block)) |value| return value; + return .{ .expr = try self.cloneExprPlain(expr_id) }; + }, .field_access => |field| { const receiver = try self.cloneExprValue(field.receiver); - if (fieldFromValue(receiver, field.field)) |value| return value; + if (fieldFromValue(self.pass.program, receiver, field.field)) |value| return value; return .{ .expr = try self.addExpr(.{ .ty = expr.ty, .data = .{ .field_access = .{ .receiver = try self.materialize(receiver), .field = field.field, @@ -1613,10 +3953,17 @@ const Cloner = struct { if (call.is_cold) return .{ .expr = try self.cloneExprPlain(expr_id) }; if (!self.inline_direct_calls) return .{ .expr = try self.cloneExprPlain(expr_id) }; const has_known_shape_arg = try self.directCallHasKnownShapeArg(call.args); - if (self.inline_direct_requires_known_arg and !has_known_shape_arg) { + // A direct call carries its callee's captures by the callee's + // own capture locals: the residual call imports those locals + // into the enclosing function. In a context where a capture + // operand has been substituted away from the callee's local, + // that import would name a local the context does not have, + // so the call cannot stay residual and must inline. + const captures_foreign = self.callCapturesAreForeign(call.captures); + const callee = Ast.localDirectCallee(call) orelse return .{ .expr = try self.cloneExprPlain(expr_id) }; + if (self.inline_direct_requires_known_arg and !has_known_shape_arg and !captures_foreign) { return .{ .expr = try self.cloneExprPlain(expr_id) }; } - const callee = Ast.localDirectCallee(call) orelse return .{ .expr = try self.cloneExprPlain(expr_id) }; return try self.inlineDirectCallValue( callee, call.args, @@ -1637,6 +3984,22 @@ const Cloner = struct { return false; } + /// Whether any capture operand of a direct call would clone to something + /// other than the callee's own capture local — i.e. the call sits in a + /// context where the captured bindings have been substituted. + fn callCapturesAreForeign(self: *Cloner, captures_span: Ast.Span(Ast.CaptureOperand)) bool { + const operands = self.pass.program.captureOperandSpan(captures_span); + for (0..operands.len) |index| { + const operand = GuardedList.at(operands, index); + const local = localExpr(self.pass.program, operand.value) orelse return true; + if (self.subst.contains(local)) return true; + if (self.binderIdentityOf(local)) |identity| { + if (self.binder_subst.contains(identity)) return true; + } + } + return false; + } + fn exprHasKnownShape(self: *Cloner, expr_id: Ast.ExprId) Allocator.Error!bool { const expr = self.pass.program.getExpr(expr_id); return switch (expr.data) { @@ -1650,10 +4013,11 @@ const Cloner = struct { .nominal, .fn_ref, => (try self.pass.constructorShape(expr_id)) != null, + .list, .str_lit, .bytes_lit => self.inline_list_source_construction, .field_access => |field| blk: { const receiver_local = localExpr(self.pass.program, field.receiver) orelse break :blk false; const receiver = self.subst.get(receiver_local) orelse break :blk false; - const value = fieldFromValue(receiver, field.field) orelse break :blk false; + const value = fieldFromValue(self.pass.program, receiver, field.field) orelse break :blk false; break :blk (try self.pass.shapeFromValue(value)) != null; }, .tuple_access => |access| blk: { @@ -1662,37 +4026,72 @@ const Cloner = struct { const value = itemFromValue(tuple, access.elem_index) orelse break :blk false; break :blk (try self.pass.shapeFromValue(value)) != null; }, + .static_data_candidate => |candidate| try self.exprHasKnownShape(candidate.runtime_expr), .comptime_branch_taken => |taken| try self.exprHasKnownShape(taken.body), .comptime_exhaustiveness_failed => false, else => false, }; } + /// Total work budget for walking one substitution-candidate value. + /// + /// A known value is not always a small finite tree. A loop-carried value + /// can reference itself through the fixpoint of a recursive construction + /// (e.g. an iterator wrapped around itself a runtime number of times, + /// where the step callable's capture reaches the nominal whose backing + /// reaches the callable again), and a deep statically-built chain shares + /// substructure between levels, so a per-level depth budget still permits + /// combinatorially many paths through the shared nodes. The budget is + /// therefore spent per NODE VISIT — one shared counter across the whole + /// walk — which bounds total work absolutely for cycles and shared + /// structure alike. See design.md "Core Principles" on bounded post-check + /// walks. + /// + /// A work budget is the right bound here, rather than a visited set, + /// because this predicate is allowed to answer "no" spuriously: declining + /// a substitution keeps the construction materialized, which is a missed + /// optimization and never a miscompile. A cyclic value exhausts the + /// budget and gets "no" — the correct answer, since a self-referential + /// value cannot be substituted anyway — and a value large enough to + /// exhaust it honestly is one whose substitution would bloat the clone + /// regardless. Value identity is also too murky for a reliable visited + /// set: values are by-value unions holding slices, with only the nominal + /// backing behind a stable pointer. + const value_substitute_work_budget: u32 = 4096; + fn valueCanSubstitute(self: *Cloner, value: Value) bool { + var budget: u32 = value_substitute_work_budget; + return self.valueCanSubstituteBudgeted(value, &budget); + } + + fn valueCanSubstituteBudgeted(self: *Cloner, value: Value, budget: *u32) bool { + if (budget.* == 0) return false; + budget.* -= 1; return switch (value) { .expr => |expr| self.exprCanSubstitute(expr), + .static_data_candidate => |candidate| self.valueCanSubstituteBudgeted(candidate.runtime.*, budget), .tag => |tag| blk: { for (tag.payloads) |payload| { - if (!self.valueCanSubstitute(payload)) break :blk false; + if (!self.valueCanSubstituteBudgeted(payload, budget)) break :blk false; } break :blk true; }, .record => |record| blk: { for (record.fields) |field| { - if (!self.valueCanSubstitute(field.value)) break :blk false; + if (!self.valueCanSubstituteBudgeted(field.value, budget)) break :blk false; } break :blk true; }, .tuple => |tuple| blk: { for (tuple.items) |item| { - if (!self.valueCanSubstitute(item)) break :blk false; + if (!self.valueCanSubstituteBudgeted(item, budget)) break :blk false; } break :blk true; }, - .nominal => |nominal| self.valueCanSubstitute(nominal.backing.*), + .nominal => |nominal| self.valueCanSubstituteBudgeted(nominal.backing.*, budget), .callable => |callable| blk: { for (callable.captures) |capture| { - if (!self.valueCanSubstitute(capture.value)) break :blk false; + if (!self.valueCanSubstituteBudgeted(capture.value, budget)) break :blk false; } break :blk true; }, @@ -1711,6 +4110,7 @@ const Cloner = struct { .bytes_lit, => true, .fn_ref => |fn_ref| self.captureOperandSpanCanSubstitute(fn_ref.captures), + .static_data_candidate => |candidate| self.exprCanSubstitute(candidate.runtime_expr), .field_access => |field| self.exprCanSubstitute(field.receiver), .tuple_access => |access| self.exprCanSubstitute(access.tuple), else => false, @@ -1766,6 +4166,10 @@ const Cloner = struct { .name = tag.name, .payloads = try self.cloneExprSpan(tag.payloads), } }, + .static_data_candidate => |candidate| .{ .static_data_candidate = .{ + .static_data = candidate.static_data, + .runtime_expr = try self.cloneExpr(candidate.runtime_expr), + } }, .nominal => |backing| .{ .nominal = try self.cloneExpr(backing) }, .let_ => |let_| try self.cloneLet(let_), .lambda, @@ -1802,7 +4206,7 @@ const Cloner = struct { .final_else = try self.cloneExpr(if_.final_else), } }, .block => |block| return try self.cloneBlock(expr.ty, block), - .loop_ => |loop| return try self.cloneLoop(expr.ty, loop), + .loop_ => |loop| return try self.materialize(try self.cloneLoopValue(expr.ty, loop)), .break_ => |maybe| .{ .break_ = if (maybe) |value| try self.cloneExpr(value) else null }, .continue_ => |continue_| try self.cloneContinue(continue_), .if_initialized_payload => |payload_switch| .{ .if_initialized_payload = .{ @@ -1813,21 +4217,36 @@ const Cloner = struct { .initialized = try self.cloneExpr(payload_switch.initialized), .uninitialized = try self.cloneExpr(payload_switch.uninitialized), } }, - .try_sequence => |sequence| .{ .try_sequence = .{ - .try_expr = try self.cloneExpr(sequence.try_expr), - .ok_local = sequence.ok_local, - .err_is_cold = sequence.err_is_cold, - .ok_body = try self.cloneExpr(sequence.ok_body), - } }, - .try_record_sequence => |sequence| .{ .try_record_sequence = .{ - .try_expr = try self.cloneExpr(sequence.try_expr), - .value_local = sequence.value_local, - .value_field = sequence.value_field, - .rest_local = sequence.rest_local, - .rest_field = sequence.rest_field, - .err_is_cold = sequence.err_is_cold, - .ok_body = try self.cloneExpr(sequence.ok_body), - } }, + .try_sequence => |sequence| blk: { + const try_expr = try self.cloneExpr(sequence.try_expr); + const shadow_start = self.changes.items.len; + try self.shadowLocal(sequence.ok_local); + const ok_body = try self.cloneExpr(sequence.ok_body); + self.restore(shadow_start); + break :blk .{ .try_sequence = .{ + .try_expr = try_expr, + .ok_local = sequence.ok_local, + .err_is_cold = sequence.err_is_cold, + .ok_body = ok_body, + } }; + }, + .try_record_sequence => |sequence| blk: { + const try_expr = try self.cloneExpr(sequence.try_expr); + const shadow_start = self.changes.items.len; + try self.shadowLocal(sequence.value_local); + try self.shadowLocal(sequence.rest_local); + const ok_body = try self.cloneExpr(sequence.ok_body); + self.restore(shadow_start); + break :blk .{ .try_record_sequence = .{ + .try_expr = try_expr, + .value_local = sequence.value_local, + .value_field = sequence.value_field, + .rest_local = sequence.rest_local, + .rest_field = sequence.rest_field, + .err_is_cold = sequence.err_is_cold, + .ok_body = ok_body, + } }; + }, .return_ => |ret| .{ .return_ = .{ .value = try self.cloneExpr(ret.value), .target = ret.target, @@ -1860,14 +4279,90 @@ const Cloner = struct { return rest; } self.restore(change_start); + if (try self.bindPatToPendingReusableValue(let_.bind, let_.value, false, value)) { + const rest = try self.cloneExprValue(let_.rest); + self.restore(change_start); + return rest; + } + // A branch-built value cannot bind as one value; the binding and the + // let's continuation sink into the branches instead, where each + // branch's constructor is known. + if (try self.cloneLetOfCase(let_, value_expr)) |data| { + const rest_ty = self.pass.program.getExpr(let_.rest).ty; + return .{ .expr = try self.addExpr(.{ .ty = rest_ty, .data = data }) }; + } + // Name the value's opaque leaves and pin them at this position: the + // same computations in the same order, but the bound name keeps its + // structured value for the continuation. + { + const pat = self.pass.program.getPat(let_.bind); + const self_referential = switch (pat.data) { + .bind => |local| localUseCountInExpr(self.pass.program, local, let_.value) != 0, + else => false, + }; + if (!self_referential) { + const pending_before = self.pending.items.len; + const reusable = try self.makeReusableForMatch(value); + if (try self.bindPatToValue(let_.bind, reusable)) { + const rest = try self.materialize(try self.cloneExprValue(let_.rest)); + self.restore(change_start); + return .{ .expr = try self.flushPendingSince(pending_before, rest) }; + } + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_before); + } + } + try self.shadowPatLocals(let_.bind); + const rest = try self.cloneExpr(let_.rest); + self.restore(change_start); return .{ .expr = try self.addExpr(.{ .ty = self.pass.program.getExpr(let_.rest).ty, .data = .{ .let_ = .{ .bind = try self.clonePat(let_.bind), .value = value_expr, - .rest = try self.cloneExpr(let_.rest), + .rest = rest, .comptime_site = let_.comptime_site, } } }) }; } + /// Dissolve a binding by naming its value's opaque leaves as pending + /// bindings: the value keeps its structure, uses substitute leaf + /// references, and the pending bindings are emitted where the stack next + /// flushes, still dominating every use. Sound only when every named leaf + /// is an effect-free computation created before any effect in its region, + /// and the value does not reference its own binder. Returns false with + /// all speculative work undone. + fn bindPatToPendingReusableValue( + self: *Cloner, + pat_id: Ast.PatId, + source_value: Ast.ExprId, + recursive: bool, + value: Value, + ) Common.LowerError!bool { + const pat = self.pass.program.getPat(pat_id); + const self_referential = switch (pat.data) { + .bind => |local| localUseCountInExpr(self.pass.program, local, source_value) != 0, + else => recursive, + }; + if (self_referential) return false; + if (self.effect_marks != self.region_entry_marks) return false; + + const pending_before = self.pending.items.len; + const change_before = self.changes.items.len; + const reusable = try self.makeReusableForMatch(value); + for (self.pending.items[pending_before..]) |pend| { + if (!exprHasNoObservableEffect(self.pass.program, self.pass.fn_effect_free, pend.value, false)) { + self.restore(change_before); + self.pending.shrinkRetainingCapacity(pending_before); + return false; + } + } + if (!try self.bindPatToReusableValue(pat_id, reusable)) { + self.restore(change_before); + self.pending.shrinkRetainingCapacity(pending_before); + return false; + } + return true; + } + fn cloneLet(self: *Cloner, let_: anytype) Common.LowerError!Ast.ExprData { const value = try self.cloneExprValue(let_.value); const value_expr = try self.materialize(value); @@ -1880,7 +4375,10 @@ const Cloner = struct { } else blk: { self.restore(change_start); if (try self.cloneLetOfCase(let_, value_expr)) |data| return data; - break :blk try self.cloneExpr(let_.rest); + try self.shadowPatLocals(let_.bind); + const rest = try self.cloneExpr(let_.rest); + self.restore(change_start); + break :blk rest; }; return .{ .let_ = .{ .bind = try self.clonePat(let_.bind), @@ -1892,84 +4390,160 @@ const Cloner = struct { fn cloneLetOfCase(self: *Cloner, let_: anytype, value_expr: Ast.ExprId) Common.LowerError!?Ast.ExprData { const value_data = self.pass.program.getExpr(value_expr).data; - const match = switch (value_data) { - .match_ => |match| match, - else => return null, - }; + switch (value_data) { + .match_ => |match| { + const branches = try GuardedList.dupe(self.pass.allocator, Ast.Branch, self.pass.program.branchSpan(match.branches)); + defer self.pass.allocator.free(branches); - const branches = try GuardedList.dupe(self.pass.allocator, Ast.Branch, self.pass.program.branchSpan(match.branches)); - defer self.pass.allocator.free(branches); + var rewritten = try self.pass.allocator.alloc(Ast.Branch, branches.len); + defer self.pass.allocator.free(rewritten); - var rewritten = try self.pass.allocator.alloc(Ast.Branch, branches.len); - defer self.pass.allocator.free(rewritten); + for (branches, 0..) |branch, index| { + const change_start = self.changes.items.len; + try self.shadowPatLocals(branch.pat); + const body = (try self.cloneLetCaseBranchBody(let_, branch.body)) orelse { + self.restore(change_start); + return null; + }; + self.restore(change_start); + rewritten[index] = .{ + .pat = branch.pat, + .guard = branch.guard, + .body = body, + }; + } - for (branches, 0..) |branch, index| { - const body = (try self.cloneLetCaseBranchBody(let_, branch.body)) orelse return null; - rewritten[index] = .{ - .pat = branch.pat, - .guard = branch.guard, - .body = body, - }; - } + return .{ .match_ = .{ + .scrutinee = match.scrutinee, + .branches = try self.pass.program.addBranchSpan(rewritten), + .comptime_site = match.comptime_site, + } }; + }, + .if_ => |if_| { + const branches = try GuardedList.dupe(self.pass.allocator, Ast.IfBranch, self.pass.program.ifBranchSpan(if_.branches)); + defer self.pass.allocator.free(branches); - return .{ .match_ = .{ - .scrutinee = match.scrutinee, - .branches = try self.pass.program.addBranchSpan(rewritten), - .comptime_site = match.comptime_site, - } }; + var rewritten = try self.pass.allocator.alloc(Ast.IfBranch, branches.len); + defer self.pass.allocator.free(rewritten); + + for (branches, 0..) |branch, index| { + const body = (try self.cloneLetCaseBranchBody(let_, branch.body)) orelse return null; + rewritten[index] = .{ + .cond = branch.cond, + .body = body, + }; + } + const final_else = (try self.cloneLetCaseBranchBody(let_, if_.final_else)) orelse return null; + + return .{ .if_ = .{ + .branches = try self.pass.program.addIfBranchSpan(rewritten), + .final_else = final_else, + } }; + }, + else => return null, + } } fn cloneLetCaseBranchBody(self: *Cloner, let_: anytype, branch_body: Ast.ExprId) Common.LowerError!?Ast.ExprId { + // The rewritten branch flushes every pending binding it creates, so + // it is its own region. + const saved_entry_marks = self.region_entry_marks; + self.region_entry_marks = self.effect_marks; + defer self.region_entry_marks = saved_entry_marks; + const branch_expr = self.pass.program.getExpr(branch_body); switch (branch_expr.data) { .block => |block| { const change_start = self.changes.items.len; + const pending_entry = self.pending.items.len; const source = try GuardedList.dupe(self.pass.allocator, Ast.StmtId, self.pass.program.stmtSpan(block.statements)); defer self.pass.allocator.free(source); - const statements = try self.pass.allocator.alloc(Ast.StmtId, source.len); - defer self.pass.allocator.free(statements); - for (source, 0..) |stmt, index| { - statements[index] = try self.cloneStmt(stmt); + var statements = std.ArrayList(Ast.StmtId).empty; + defer statements.deinit(self.pass.allocator); + for (source) |stmt| { + const pending_start = self.pending.items.len; + const cloned = try self.cloneStmt(stmt); + try self.appendPendingStmtsSince(pending_start, &statements); + if (cloned) |cloned_stmt| try statements.append(self.pass.allocator, cloned_stmt); } + const pending_final = self.pending.items.len; const final_value = try self.cloneExprValue(block.final_expr); const rest_ty = self.pass.program.getExpr(let_.rest).ty; - if (!try self.bindPatToReusableValue(let_.bind, final_value)) { + if (!try self.bindPatToBranchValue(let_.bind, block.final_expr, final_value)) { if (try self.cloneDivergentAtType(block.final_expr, rest_ty)) |divergent| { self.restore(change_start); + try self.appendPendingStmtsSince(pending_final, &statements); return try self.addExpr(.{ .ty = rest_ty, .data = .{ .block = .{ - .statements = try self.pass.program.addStmtSpan(statements), + .statements = try self.pass.program.addStmtSpan(statements.items), .final_expr = divergent, } } }); } self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_entry); return null; } + try self.appendPendingStmtsSince(pending_final, &statements); const rest = try self.cloneExpr(let_.rest); self.restore(change_start); return try self.addExpr(.{ .ty = rest_ty, .data = .{ .block = .{ - .statements = try self.pass.program.addStmtSpan(statements), + .statements = try self.pass.program.addStmtSpan(statements.items), .final_expr = rest, } } }); }, else => { + const pending_entry = self.pending.items.len; const branch_value = try self.cloneExprValue(branch_body); const change_start = self.changes.items.len; - if (!try self.bindPatToReusableValue(let_.bind, branch_value)) { + if (!try self.bindPatToBranchValue(let_.bind, branch_body, branch_value)) { self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_entry); return null; } - const rest = try self.cloneExpr(let_.rest); + const rest = try self.flushPendingSince(pending_entry, try self.cloneExpr(let_.rest)); self.restore(change_start); return rest; }, } } + /// Bind a sunk let's pattern to one branch's result value: directly when + /// the value substitutes wholesale, otherwise by naming its opaque leaves + /// as pending bindings the caller pins at the branch's position — the + /// same computations in the same order. Sinking a continuation into the + /// branches pays for itself only when a branch yields a constructor the + /// binding consumes structurally; an opaque branch value gains nothing + /// and would only duplicate the continuation, so it declines. + fn bindPatToBranchValue( + self: *Cloner, + pat_id: Ast.PatId, + source_value: Ast.ExprId, + value: Value, + ) Common.LowerError!bool { + switch (value) { + .expr => return false, + else => {}, + } + if (try self.bindPatToReusableValue(pat_id, value)) return true; + const pat = self.pass.program.getPat(pat_id); + const self_referential = switch (pat.data) { + .bind => |local| localUseCountInExpr(self.pass.program, local, source_value) != 0, + else => false, + }; + if (self_referential) return false; + const change_before = self.changes.items.len; + const pending_before = self.pending.items.len; + const reusable = try self.makeReusableForMatch(value); + if (try self.bindPatToValue(pat_id, reusable)) return true; + self.restore(change_before); + self.pending.shrinkRetainingCapacity(pending_before); + return false; + } + fn cloneDivergentAtType(self: *Cloner, expr_id: Ast.ExprId, ty: Type.TypeId) Common.LowerError!?Ast.ExprId { const expr = self.pass.program.getExpr(expr_id); return switch (expr.data) { @@ -1983,7 +4557,7 @@ const Cloner = struct { }; } - fn cloneLoop(self: *Cloner, ty: Type.TypeId, loop: anytype) Common.LowerError!Ast.ExprId { + fn cloneLoopValue(self: *Cloner, ty: Type.TypeId, loop: anytype) Common.LowerError!Value { const params = try GuardedList.dupe(self.pass.allocator, Ast.TypedLocal, self.pass.program.typedLocalSpan(loop.params)); defer self.pass.allocator.free(params); const initial_values = try GuardedList.dupe(self.pass.allocator, Ast.ExprId, self.pass.program.exprSpan(loop.initial_values)); @@ -1994,6 +4568,16 @@ const Cloner = struct { defer self.pass.allocator.free(values); const shapes = try self.pass.arena.allocator().alloc(Shape, initial_values.len); var has_constructor = false; + // A loop-carried value that begins as an iterator construction only + // reveals its constructor shape after that construction inlines. An + // adapter constructor (e.g. `List.iter(list)`, `Iter.map(inner, f)`) + // returns a record whose leaves the split threads as scalars, but its + // arguments (the source list, the inner iterator) need not themselves + // be known shapes. So expose the initial value's constructor by + // inlining its construction call regardless of argument shape; the + // per-argument known-shape gate governs only residual body calls. + const saved_requires_known_arg = self.inline_direct_requires_known_arg; + if (self.force_loop_initial_inline) self.inline_direct_requires_known_arg = false; for (initial_values, 0..) |initial, index| { values[index] = try self.cloneExprValue(initial); if (try self.pass.shapeFromValue(values[index])) |shape| { @@ -2003,39 +4587,141 @@ const Cloner = struct { shapes[index] = .{ .any = valueType(self.pass.program, values[index]) }; } } - - if (!has_constructor) { - const initial_span = try self.valuesToExprSpan(values); - return try self.addExpr(.{ .ty = ty, .data = .{ .loop_ = .{ - .params = loop.params, - .initial_values = initial_span, - .body = try self.cloneExpr(loop.body), - } } }); - } + self.inline_direct_requires_known_arg = saved_requires_known_arg; const change_start = self.changes.items.len; defer self.restore(change_start); - var new_params = std.ArrayList(Ast.TypedLocal).empty; - defer new_params.deinit(self.pass.allocator); + // A loop-carried variable that was bound to a known constructor before the + // loop leaves that value in `binder_subst`, keyed on its source binder. + // Every back edge reassigns the variable, so its pre-loop value is not + // what the slot carries inside the loop. Reads sharing that binder (the + // reassigned copies feeding `continue`) must resolve to the value the slot + // actually holds, so drop those pre-loop values before cloning the body. + for (initial_values) |initial| try self.dropCarriedBinderValue(initial); + + // Splitting a slot into its shape leaves is only sound when every back + // edge can hand those leaves back. Whether a back edge can is knowable + // only while cloning the body: an advanced successor becomes a known + // constructor value through step inlining and known-tag collapse, which + // the source expressions do not show. So the split is decided by + // attempt: substitute each carried slot with its entry shape's leaves, + // clone the body, and let every back edge either supply the leaves or + // demote the specific leaves it cannot supply. A demoted leaf becomes a + // runtime scalar over its finite value set (e.g. an entry-known tag a + // back edge flips to a sibling tag) while its sibling leaves stay split. + // The failed clone is discarded and the attempt repeats. Each retry + // erases at least one constructor leaf, so attempts are bounded by the + // leaf count. + while (has_constructor) { + var new_params = std.ArrayList(Ast.TypedLocal).empty; + defer new_params.deinit(self.pass.allocator); + + var new_initials = std.ArrayList(Ast.ExprId).empty; + defer new_initials.deinit(self.pass.allocator); + + const split_start = self.changes.items.len; + for (params, shapes, values) |param, shape, value| { + const param_value = try self.valueFromShapeArgs(shape, &new_params); + try self.putSubst(param.local, param_value); + try self.appendExprsFromValue(shape, value, &new_initials); + } + + try self.loop_stack.append(self.pass.allocator, .{ .values = shapes, .any_demoted = false }); + const body = try self.cloneExpr(loop.body); + const frame = self.loop_stack.pop() orelse Common.invariant("loop stack underflow after split attempt"); - var new_initials = std.ArrayList(Ast.ExprId).empty; - defer new_initials.deinit(self.pass.allocator); + if (!frame.any_demoted) { + return .{ .expr = try self.addExpr(.{ .ty = ty, .data = .{ .loop_ = .{ + .params = try self.pass.program.addTypedLocalSpan(new_params.items), + .initial_values = try self.pass.program.addExprSpan(new_initials.items), + .body = body, + } } }) }; + } - for (params, shapes, values) |param, shape, value| { - const param_value = try self.valueFromShapeArgs(shape, &new_params); - try self.putSubst(param.local, param_value); - try self.appendExprsFromValue(shape, value, &new_initials); + self.restore(split_start); + // Back edges demoted their unsupplied leaves in place. Any slot that + // still carries constructor structure is worth another split attempt. + has_constructor = false; + for (shapes) |shape| switch (shape) { + .any => {}, + else => has_constructor = true, + }; } - try self.loop_stack.append(self.pass.allocator, .{ .values = shapes }); - defer _ = self.loop_stack.pop(); + const whole_shapes = try self.pass.arena.allocator().alloc(Shape, params.len); + for (params, 0..) |param, index| whole_shapes[index] = .{ .any = param.ty }; + + const initial_span = try self.valuesToExprSpan(values); + for (params) |param| try self.shadowLocal(param.local); + try self.loop_stack.append(self.pass.allocator, .{ .values = whole_shapes, .any_demoted = false }); + const body = try self.cloneExpr(loop.body); + if (self.loop_stack.pop() == null) Common.invariant("loop stack underflow after whole-state body clone"); + return .{ .expr = try self.addExpr(.{ .ty = ty, .data = .{ .loop_ = .{ + .params = loop.params, + .initial_values = initial_span, + .body = body, + } } }) }; + } - return try self.addExpr(.{ .ty = ty, .data = .{ .loop_ = .{ - .params = try self.pass.program.addTypedLocalSpan(new_params.items), - .initial_values = try self.pass.program.addExprSpan(new_initials.items), - .body = try self.cloneExpr(loop.body), - } } }); + /// Remove the pre-loop `binder_subst` value for the variable carried by a + /// loop slot whose initial value is that variable. The removal is recorded on + /// the change log so it is restored when the loop clone finishes. + fn dropCarriedBinderValue(self: *Cloner, initial: Ast.ExprId) Allocator.Error!void { + const local = localExpr(self.pass.program, initial) orelse return; + const identity = self.binderIdentityOf(local) orelse return; + const previous = self.binder_subst.get(identity) orelse return; + try self.changes.append(self.pass.allocator, .{ + .key = .{ .binder = identity }, + .previous = previous, + }); + _ = self.binder_subst.remove(identity); + } + + /// A block whose statements all dissolve — each binds a substitutable + /// value, or names an effect-free computation that becomes a pending + /// binding — is transparent to value flow: its result keeps the final + /// expression's structure. A statement that must stay a statement (an + /// effect, a runtime destructure, control flow) pins the block, which + /// then materializes as written. Returns null on a pinned block with all + /// speculative work undone. + fn cloneBlockValue(self: *Cloner, block: anytype) Common.LowerError!?Value { + const change_start = self.changes.items.len; + const pending_entry = self.pending.items.len; + + const source = try GuardedList.dupe(self.pass.allocator, Ast.StmtId, self.pass.program.stmtSpan(block.statements)); + defer self.pass.allocator.free(source); + + for (source) |stmt_id| { + const stmt = self.pass.program.getStmt(stmt_id); + const let_ = switch (stmt) { + .let_ => |let_| let_, + // A discarded effect-free expression performs no observable + // work, so the statement dissolves with the block. + .expr => |stmt_expr| { + if (exprHasNoObservableEffect(self.pass.program, self.pass.fn_effect_free, stmt_expr, false)) continue; + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_entry); + return null; + }, + else => { + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_entry); + return null; + }, + }; + const value = try self.cloneExprValue(let_.value); + if (try self.bindPatToReusableValue(let_.pat, value)) continue; + if (!try self.bindPatToPendingReusableValue(let_.pat, let_.value, let_.recursive, value)) { + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_entry); + return null; + } + } + + const final = try self.cloneExprValue(block.final_expr); + self.restore(change_start); + return final; } fn cloneBlock(self: *Cloner, ty: Type.TypeId, block: anytype) Common.LowerError!Ast.ExprId { @@ -2045,22 +4731,49 @@ const Cloner = struct { const source = try GuardedList.dupe(self.pass.allocator, Ast.StmtId, self.pass.program.stmtSpan(block.statements)); defer self.pass.allocator.free(source); - const statements = try self.pass.allocator.alloc(Ast.StmtId, source.len); - defer self.pass.allocator.free(statements); + var statements = std.ArrayList(Ast.StmtId).empty; + defer statements.deinit(self.pass.allocator); for (source, 0..) |stmt, index| { - statements[index] = try self.cloneStmt(stmt); + // A binding statement is a let expression over the block's tail. + // Cloning it as one lets a branch-built value sink the tail into + // the branches, where each branch's constructor is known. + switch (self.pass.program.getStmt(stmt)) { + .let_ => |let_| if (!let_.recursive) { + const tail = try self.pass.program.addExpr(.{ .ty = ty, .data = .{ .block = .{ + .statements = try self.pass.program.addStmtSpan(source[index + 1 ..]), + .final_expr = block.final_expr, + } } }); + const synthetic = try self.pass.program.addExpr(.{ .ty = ty, .data = .{ .let_ = .{ + .bind = let_.pat, + .value = let_.value, + .rest = tail, + .comptime_site = let_.comptime_site, + } } }); + return try self.addExpr(.{ .ty = ty, .data = .{ .block = .{ + .statements = try self.pass.program.addStmtSpan(statements.items), + .final_expr = try self.cloneExpr(synthetic), + } } }); + }, + else => {}, + } + const pending_start = self.pending.items.len; + const cloned = try self.cloneStmt(stmt); + try self.appendPendingStmtsSince(pending_start, &statements); + if (cloned) |cloned_stmt| try statements.append(self.pass.allocator, cloned_stmt); } return try self.addExpr(.{ .ty = ty, .data = .{ .block = .{ - .statements = try self.pass.program.addStmtSpan(statements), + .statements = try self.pass.program.addStmtSpan(statements.items), .final_expr = try self.cloneExpr(block.final_expr), } } }); } fn cloneContinue(self: *Cloner, continue_: anytype) Common.LowerError!Ast.ExprData { - const loop = self.loop_stack.getLastOrNull() orelse return .{ .continue_ = .{ + const frame_count = self.loop_stack.items.len; + if (frame_count == 0) return .{ .continue_ = .{ .values = try self.cloneExprSpan(continue_.values), } }; + const loop = self.loop_stack.items[frame_count - 1]; const values = self.pass.program.exprSpan(continue_.values); const source_values = try GuardedList.dupe(self.pass.allocator, Ast.ExprId, values); defer self.pass.allocator.free(source_values); @@ -2069,15 +4782,18 @@ const Cloner = struct { var new_values = std.ArrayList(Ast.ExprId).empty; defer new_values.deinit(self.pass.allocator); - for (loop.values, source_values) |shape, value_expr| { + for (loop.values, source_values, 0..) |shape, value_expr, slot_index| { const value = try self.cloneExprValue(value_expr); - if (!shapeMatchesValue(self.pass.program, shape, value)) { - if (!try self.appendFieldReadExprsFromValue(shape, value, &new_values)) { - Common.invariant("continue value did not match specialized loop state"); - } - continue; + const supplied = try self.supplyLoopSlotLeaves(shape, value, &new_values); + if (supplied.demoted) { + // This back edge could not supply some of the slot's entry-shape + // leaves. Record the per-leaf demotion so the split attempt + // carries those leaves as runtime scalars while their siblings + // stay split; the values emitted here belong to a clone the + // attempt discards and retries. + self.loop_stack.items[frame_count - 1].values[slot_index] = supplied.shape; + self.loop_stack.items[frame_count - 1].any_demoted = true; } - try self.appendExprsFromValue(shape, value, &new_values); } return .{ .continue_ = .{ @@ -2107,10 +4823,11 @@ const Cloner = struct { const callee = Ast.localDirectCallee(call) orelse return .{ .call_proc = .{ .callee = call.callee, .args = try self.cloneExprSpan(call.args), + .captures = try self.cloneCaptureOperandSpan(call.captures), .is_cold = call.is_cold, } }; const raw = @intFromEnum(callee); - if (raw < self.pass.plans.len) { + if (self.rewrite_call_patterns and raw < self.pass.plans.len) { const source_args = self.pass.program.exprSpan(call.args); const args = try GuardedList.dupe(self.pass.allocator, Ast.ExprId, source_args); defer self.pass.allocator.free(args); @@ -2187,10 +4904,16 @@ const Cloner = struct { value: Value, out: *std.ArrayList(Ast.ExprId), ) Common.LowerError!void { + const structural_value = switch (value) { + .static_data_candidate => |candidate| candidate.runtime.*, + else => value, + }; switch (shape) { - .any => try out.append(self.pass.allocator, try self.materialize(value)), + .any => { + try out.append(self.pass.allocator, try self.materialize(value)); + }, .tag => |tag| { - const tag_value = switch (value) { + const tag_value = switch (structural_value) { .tag => |tag_value| tag_value, else => Common.invariant("tag call pattern matched a non-tag value"), }; @@ -2199,17 +4922,17 @@ const Cloner = struct { } }, .record => |record| { - const record_value = switch (value) { + const record_value = switch (structural_value) { .record => |record_value| record_value, else => Common.invariant("record call pattern matched a non-record value"), }; for (record.fields, record_value.fields) |field_shape, field| { - if (field_shape.name != field.name) Common.invariant("record call-pattern field order changed after matching"); + if (!self.pass.program.names.recordFieldLabelTextEql(field_shape.name, field.name)) Common.invariant("record call-pattern field order changed after matching"); try self.appendExprsFromValue(field_shape.shape, field.value, out); } }, .tuple => |tuple| { - const tuple_value = switch (value) { + const tuple_value = switch (structural_value) { .tuple => |tuple_value| tuple_value, else => Common.invariant("tuple call pattern matched a non-tuple value"), }; @@ -2218,14 +4941,14 @@ const Cloner = struct { } }, .nominal => |nominal| { - const nominal_value = switch (value) { + const nominal_value = switch (structural_value) { .nominal => |nominal_value| nominal_value, else => Common.invariant("nominal call pattern matched a non-nominal value"), }; try self.appendExprsFromValue(nominal.backing.*, nominal_value.backing.*, out); }, .callable => |callable| { - const callable_value = switch (value) { + const callable_value = switch (structural_value) { .callable => |callable_value| callable_value, else => Common.invariant("callable call pattern matched a non-callable value"), }; @@ -2236,62 +4959,175 @@ const Cloner = struct { } } - fn appendFieldReadExprsFromValue( + /// Supply a loop slot's entry-shape leaves from a back edge's value, + /// appending one expr per leaf to `out` in the order `valueFromShapeArgs` + /// created the leaf params. Where the value structurally matches the shape, + /// the split leaves are emitted directly (or read from an opaque expr via + /// field access). Where a sub-path of the value cannot supply the shape's + /// leaves — a back edge flipping an entry-known tag to a sibling tag, or a + /// value that is not the shape's constructor — that sub-path demotes to + /// `.any` and its whole value materializes as one runtime scalar over its + /// finite value set, while its sibling leaves stay split. The returned + /// shape carries the demotions; `demoted` is set when any leaf demoted. + fn supplyLoopSlotLeaves( self: *Cloner, shape: Shape, value: Value, out: *std.ArrayList(Ast.ExprId), - ) Common.LowerError!bool { + ) Common.LowerError!SuppliedSlot { if (shapeMatchesValue(self.pass.program, shape, value)) { try self.appendExprsFromValue(shape, value, out); - return true; + return .{ .shape = shape, .demoted = false }; } switch (shape) { .any => { try out.append(self.pass.allocator, try self.materialize(value)); - return true; + return .{ .shape = shape, .demoted = false }; }, - .record => |record| { - const receiver = switch (value) { - .expr => |expr| expr, - else => return false, + .tag => |tag| { + const value_tag = switch (value) { + .tag => |value_tag| value_tag, + else => return try self.demoteLoopSlotLeaf(tag.ty, value, out), }; - if (!canReadFieldsFromExpr(self.pass.program, receiver)) return false; - for (record.fields) |field| { - const field_expr = try self.addExpr(.{ .ty = shapeType(field.shape), .data = .{ .field_access = .{ - .receiver = receiver, - .field = field.name, - } } }); - if (!try self.appendFieldReadExprsFromValue(field.shape, .{ .expr = field_expr }, out)) return false; + if (!self.pass.program.names.tagLabelTextEql(value_tag.name, tag.name) or + !sameType(self.pass.program, tag.ty, value_tag.ty) or + value_tag.payloads.len != tag.payloads.len) + { + return try self.demoteLoopSlotLeaf(tag.ty, value, out); } - return true; + const payloads = try self.pass.arena.allocator().alloc(Shape, tag.payloads.len); + var demoted = false; + for (tag.payloads, value_tag.payloads, 0..) |payload_shape, payload_value, index| { + const supplied = try self.supplyLoopSlotLeaves(payload_shape, payload_value, out); + payloads[index] = supplied.shape; + demoted = demoted or supplied.demoted; + } + return .{ .shape = .{ .tag = .{ .ty = tag.ty, .name = tag.name, .payloads = payloads } }, .demoted = demoted }; + }, + .record => |record| { + switch (value) { + .record => |value_record| { + if (sameType(self.pass.program, record.ty, value_record.ty) and + value_record.fields.len == record.fields.len) + { + const fields = try self.pass.arena.allocator().alloc(FieldShape, record.fields.len); + var demoted = false; + for (record.fields, value_record.fields, 0..) |field_shape, field_value, index| { + if (!self.pass.program.names.recordFieldLabelTextEql(field_shape.name, field_value.name)) return try self.demoteLoopSlotLeaf(record.ty, value, out); + const supplied = try self.supplyLoopSlotLeaves(field_shape.shape, field_value.value, out); + fields[index] = .{ .name = field_shape.name, .shape = supplied.shape }; + demoted = demoted or supplied.demoted; + } + return .{ .shape = .{ .record = .{ .ty = record.ty, .fields = fields } }, .demoted = demoted }; + } + }, + .expr => |receiver| { + if (canReadFieldsFromExpr(self.pass.program, receiver)) { + const fields = try self.pass.arena.allocator().alloc(FieldShape, record.fields.len); + var demoted = false; + for (record.fields, 0..) |field_shape, index| { + const field_expr = try self.addExpr(.{ .ty = shapeType(field_shape.shape), .data = .{ .field_access = .{ + .receiver = receiver, + .field = field_shape.name, + } } }); + const supplied = try self.supplyLoopSlotLeaves(field_shape.shape, .{ .expr = field_expr }, out); + fields[index] = .{ .name = field_shape.name, .shape = supplied.shape }; + demoted = demoted or supplied.demoted; + } + return .{ .shape = .{ .record = .{ .ty = record.ty, .fields = fields } }, .demoted = demoted }; + } + }, + else => {}, + } + return try self.demoteLoopSlotLeaf(record.ty, value, out); }, .tuple => |tuple| { - const receiver = switch (value) { - .expr => |expr| expr, - else => return false, + switch (value) { + .tuple => |value_tuple| { + if (sameType(self.pass.program, tuple.ty, value_tuple.ty) and + value_tuple.items.len == tuple.items.len) + { + const items = try self.pass.arena.allocator().alloc(Shape, tuple.items.len); + var demoted = false; + for (tuple.items, value_tuple.items, 0..) |item_shape, item_value, index| { + const supplied = try self.supplyLoopSlotLeaves(item_shape, item_value, out); + items[index] = supplied.shape; + demoted = demoted or supplied.demoted; + } + return .{ .shape = .{ .tuple = .{ .ty = tuple.ty, .items = items } }, .demoted = demoted }; + } + }, + .expr => |receiver| { + if (canReadFieldsFromExpr(self.pass.program, receiver)) { + const items = try self.pass.arena.allocator().alloc(Shape, tuple.items.len); + var demoted = false; + for (tuple.items, 0..) |item_shape, index| { + const item_expr = try self.addExpr(.{ .ty = shapeType(item_shape), .data = .{ .tuple_access = .{ + .tuple = receiver, + .elem_index = @as(u32, @intCast(index)), + } } }); + const supplied = try self.supplyLoopSlotLeaves(item_shape, .{ .expr = item_expr }, out); + items[index] = supplied.shape; + demoted = demoted or supplied.demoted; + } + return .{ .shape = .{ .tuple = .{ .ty = tuple.ty, .items = items } }, .demoted = demoted }; + } + }, + else => {}, + } + return try self.demoteLoopSlotLeaf(tuple.ty, value, out); + }, + .nominal => |nominal| { + switch (value) { + .nominal => |value_nominal| { + if (sameType(self.pass.program, nominal.ty, value_nominal.ty)) { + const supplied = try self.supplyLoopSlotLeaves(nominal.backing.*, value_nominal.backing.*, out); + const backing = try self.pass.arena.allocator().create(Shape); + backing.* = supplied.shape; + return .{ .shape = .{ .nominal = .{ .ty = nominal.ty, .backing = backing } }, .demoted = supplied.demoted }; + } + }, + else => {}, + } + return try self.demoteLoopSlotLeaf(nominal.ty, value, out); + }, + .callable => |callable| { + const value_callable = switch (value) { + .callable => |value_callable| value_callable, + else => return try self.demoteLoopSlotLeaf(callable.ty, value, out), }; - if (!canReadFieldsFromExpr(self.pass.program, receiver)) return false; - for (tuple.items, 0..) |item, index| { - const item_expr = try self.addExpr(.{ .ty = shapeType(item), .data = .{ .tuple_access = .{ - .tuple = receiver, - .elem_index = @as(u32, @intCast(index)), - } } }); - if (!try self.appendFieldReadExprsFromValue(item, .{ .expr = item_expr }, out)) return false; + if (!sameType(self.pass.program, callable.ty, value_callable.ty) or + !callableTargetMatches(self.pass.program, callable.fn_id, value_callable.fn_id) or + value_callable.captures.len != callable.captures.len) + { + return try self.demoteLoopSlotLeaf(callable.ty, value, out); } - return true; + const captures = try self.pass.arena.allocator().alloc(Shape, callable.captures.len); + var demoted = false; + for (callable.captures, value_callable.captures, 0..) |capture_shape, capture_value, index| { + const supplied = try self.supplyLoopSlotLeaves(capture_shape, capture_value.value, out); + captures[index] = supplied.shape; + demoted = demoted or supplied.demoted; + } + return .{ .shape = .{ .callable = .{ .ty = callable.ty, .fn_id = callable.fn_id, .captures = captures } }, .demoted = demoted }; }, - .tag, - .nominal, - .callable, - => return false, } } + fn demoteLoopSlotLeaf( + self: *Cloner, + ty: Type.TypeId, + value: Value, + out: *std.ArrayList(Ast.ExprId), + ) Common.LowerError!SuppliedSlot { + try out.append(self.pass.allocator, try self.materialize(value)); + return .{ .shape = .{ .any = ty }, .demoted = true }; + } + fn cloneFieldAccess(self: *Cloner, ty: Type.TypeId, field: anytype) Common.LowerError!Ast.ExprId { const receiver = try self.cloneExprValue(field.receiver); - if (fieldFromValue(receiver, field.field)) |value| return try self.materialize(value); + if (fieldFromValue(self.pass.program, receiver, field.field)) |value| return try self.materialize(value); return try self.addExpr(.{ .ty = ty, .data = .{ .field_access = .{ .receiver = try self.materialize(receiver), .field = field.field, @@ -2327,6 +5163,22 @@ const Cloner = struct { } fn simplifyKnownMatchValue(self: *Cloner, scrutinee: Value, branches_span: Ast.Span(Ast.Branch)) Common.LowerError!?Value { + return self.selectKnownMatchValue(scrutinee, branches_span, false); + } + + /// Collapse a match whose scrutinee is a known constructor to the selected + /// branch's body. `decline_on_no_match` distinguishes the two callers: the + /// direct known-match collapse proves exhaustiveness (a known constructor + /// always selects a branch), so a miss is an invariant; case-of-case + /// distribution instead *offers* a value that a branch may not structurally + /// cover (an opaque tag payload the selection cannot verify), so it declines + /// and leaves the match materialized. + fn selectKnownMatchValue( + self: *Cloner, + scrutinee: Value, + branches_span: Ast.Span(Ast.Branch), + decline_on_no_match: bool, + ) Common.LowerError!?Value { if (scrutinee == .expr) return null; const branches = self.pass.program.branchSpan(branches_span); for (0..branches.len) |branch_index| { @@ -2337,18 +5189,17 @@ const Cloner = struct { if (!matches) continue; if (branch.guard != null) return null; - var pending_lets = std.ArrayList(PendingLet).empty; - defer pending_lets.deinit(self.pass.allocator); - + const pending_start = self.pending.items.len; const change_start = self.changes.items.len; const unsafe_count = self.unsafeLeafCount(scrutinee); - if (try self.bindPatToMatchValue(branch.pat, scrutinee, branch.body, unsafe_count, &pending_lets) == null) { + if (try self.bindPatToMatchValue(branch.pat, scrutinee, branch.body, unsafe_count) == null) { Common.invariant("known constructor match changed after reusable payload binding"); } const body = try self.cloneExprValue(branch.body); self.restore(change_start); - return try self.wrapPendingLets(body, pending_lets.items); + return try self.resolvePending(pending_start, body); } + if (decline_on_no_match) return null; Common.invariant("known constructor match had no matching branch"); } @@ -2358,75 +5209,110 @@ const Cloner = struct { value: Value, body: Ast.ExprId, unsafe_count: usize, - pending_lets: *std.ArrayList(PendingLet), ) Common.LowerError!?Value { const pat = self.pass.program.getPat(pat_id); switch (pat.data) { .bind => |local| { - const prepared = try self.valueForMatchLocal(local, value, body, unsafe_count, pending_lets); + const prepared = try self.valueForMatchLocal(local, value, body, unsafe_count); try self.putSubst(local, prepared); return prepared; }, - .wildcard => return try self.makeReusableForMatch(value, pending_lets), + .wildcard => return try self.makeReusableForMatch(value), .as => |as| { const as_uses = localUseCountInExpr(self.pass.program, as.local, body); const base = if (self.valueCanSubstitute(value) or (unsafe_count == 1 and as_uses == 1 and localUseBeforeEffect(self.pass.program, as.local, body))) value else - try self.makeReusableForMatch(value, pending_lets); - const prepared = (try self.bindPatToMatchValue(as.pattern, base, body, unsafe_count, pending_lets)) orelse return null; + try self.makeReusableForMatch(value); + const prepared = (try self.bindPatToMatchValue(as.pattern, base, body, unsafe_count)) orelse return null; try self.putSubst(as.local, prepared); return prepared; }, .record => |fields_span| { - const record = recordFromValue(value) orelse return null; const fields = self.pass.program.recordDestructSpan(fields_span); - const prepared_fields = try self.pass.arena.allocator().alloc(FieldValue, record.fields.len); - for (record.fields, 0..) |field, index| { - if (recordPatField(fields, field.name)) |field_pat| { - const prepared = (try self.bindPatToMatchValue(field_pat, field.value, body, unsafe_count, pending_lets)) orelse return null; - prepared_fields[index] = .{ - .name = field.name, - .value = prepared, - }; - } else { - prepared_fields[index] = .{ - .name = field.name, - .value = try self.makeReusableForMatch(field.value, pending_lets), - }; - } + switch (value) { + .record => |record| { + const prepared_fields = try self.pass.arena.allocator().alloc(FieldValue, record.fields.len); + for (record.fields, 0..) |field, index| { + if (recordPatField(self.pass.program, fields, field.name)) |field_pat| { + const prepared = (try self.bindPatToMatchValue(field_pat, field.value, body, unsafe_count)) orelse return null; + prepared_fields[index] = .{ + .name = field.name, + .value = prepared, + }; + } else { + prepared_fields[index] = .{ + .name = field.name, + .value = try self.makeReusableForMatch(field.value), + }; + } + } + return Value{ .record = .{ + .ty = record.ty, + .fields = prepared_fields, + } }; + }, + .nominal => |nominal| return try self.bindPatToMatchValue(pat_id, nominal.backing.*, body, unsafe_count), + .expr => |receiver| { + if (!canReadFieldsFromExpr(self.pass.program, receiver)) return null; + for (0..fields.len) |index| { + const field = GuardedList.at(fields, index); + const field_ty = self.pass.program.getPat(field.pattern).ty; + const field_expr = try self.addExpr(.{ .ty = field_ty, .data = .{ .field_access = .{ + .receiver = receiver, + .field = field.name, + } } }); + _ = (try self.bindPatToMatchValue(field.pattern, .{ .expr = field_expr }, body, unsafe_count)) orelse return null; + } + return value; + }, + else => return null, } - return Value{ .record = .{ - .ty = record.ty, - .fields = prepared_fields, - } }; }, .tuple => |items_span| { - const tuple = tupleFromValue(value) orelse return null; const pats = self.pass.program.patSpan(items_span); - if (pats.len != tuple.items.len) return null; - const items = try self.pass.arena.allocator().alloc(Value, tuple.items.len); - for (0..pats.len) |index| { - const child_pat = GuardedList.at(pats, index); - const child_value = tuple.items[index]; - items[index] = (try self.bindPatToMatchValue(child_pat, child_value, body, unsafe_count, pending_lets)) orelse return null; + switch (value) { + .tuple => |tuple| { + if (pats.len != tuple.items.len) return null; + const items = try self.pass.arena.allocator().alloc(Value, tuple.items.len); + for (0..pats.len) |index| { + const child_pat = GuardedList.at(pats, index); + const child_value = tuple.items[index]; + items[index] = (try self.bindPatToMatchValue(child_pat, child_value, body, unsafe_count)) orelse return null; + } + return Value{ .tuple = .{ + .ty = tuple.ty, + .items = items, + } }; + }, + .nominal => |nominal| return try self.bindPatToMatchValue(pat_id, nominal.backing.*, body, unsafe_count), + .expr => |receiver| { + if (!canReadFieldsFromExpr(self.pass.program, receiver)) return null; + for (0..pats.len) |index| { + const child_pat = GuardedList.at(pats, index); + const item_ty = self.pass.program.getPat(child_pat).ty; + const item_expr = try self.addExpr(.{ .ty = item_ty, .data = .{ .tuple_access = .{ + .tuple = receiver, + .elem_index = @as(u32, @intCast(index)), + } } }); + _ = (try self.bindPatToMatchValue(child_pat, .{ .expr = item_expr }, body, unsafe_count)) orelse return null; + } + return value; + }, + else => return null, } - return Value{ .tuple = .{ - .ty = tuple.ty, - .items = items, - } }; }, .tag => |tag_pat| { const tag = tagFromValue(value) orelse return null; - if (tag.name != tag_pat.name) return null; + if (!self.pass.program.names.tagLabelTextEql(tag.name, tag_pat.name)) return null; const pats = self.pass.program.patSpan(tag_pat.payloads); if (pats.len != tag.payloads.len) return null; const payloads = try self.pass.arena.allocator().alloc(Value, tag.payloads.len); for (0..pats.len) |index| { const child_pat = GuardedList.at(pats, index); const child_value = tag.payloads[index]; - payloads[index] = (try self.bindPatToMatchValue(child_pat, child_value, body, unsafe_count, pending_lets)) orelse return null; + payloads[index] = (try self.bindPatToMatchValue(child_pat, child_value, body, unsafe_count)) orelse return null; } return Value{ .tag = .{ .ty = tag.ty, @@ -2440,14 +5326,14 @@ const Cloner = struct { else => return null, }; const backing = try self.pass.arena.allocator().create(Value); - backing.* = (try self.bindPatToMatchValue(backing_pat, nominal.backing.*, body, unsafe_count, pending_lets)) orelse return null; + backing.* = (try self.bindPatToMatchValue(backing_pat, nominal.backing.*, body, unsafe_count)) orelse return null; return Value{ .nominal = .{ .ty = nominal.ty, .backing = backing, } }; }, // List patterns are not statically destructured during - // specialization; fall back to the runtime match. + // specialization; use the runtime match. .list, .int_lit, .dec_lit, @@ -2459,21 +5345,45 @@ const Cloner = struct { } } + /// Node-count threshold above which a known constructor value bound to an + /// inlined or matched local is boxed instead of substituted or reused. A + /// statically constructed adapter chain is tens of nodes; a + /// recursively-constructed chain wrapped a runtime number of times has no + /// static depth, so its fixpoint known value instead fills the shape work + /// budget and reaches thousands of nodes. Substituting that value shares it + /// into every use, where each level of specialization re-walks and + /// re-inlines the whole thing, and the total never settles. A value this + /// large is past the point where per-use specialization pays for itself, so + /// binding it once behind a local (the sanctioned dynamic boundary) both + /// bounds the work and is the right code: real chains stay an order of + /// magnitude under the threshold and keep their per-use specialization. + /// Declining to track a value is a missed optimization, never a wrong + /// lowering. See design.md "Core Principles" on bounded post-check walks. + const known_value_track_cap: usize = 512; + + /// Materialize a known value once and bind it reuse-safely, so it is no + /// longer tracked as a known constructor at its use sites. + fn boxDeepKnownValue(self: *Cloner, value: Value) Common.LowerError!Value { + return try self.makeReusableForMatch(.{ .expr = try self.materialize(value) }); + } + fn valueForMatchLocal( self: *Cloner, local: Ast.LocalId, value: Value, body: Ast.ExprId, unsafe_count: usize, - pending_lets: *std.ArrayList(PendingLet), ) Common.LowerError!Value { + if (self.knownConstructorSize(value) >= known_value_track_cap) { + return try self.boxDeepKnownValue(value); + } const uses = localUseCountInExpr(self.pass.program, local, body); if (self.valueCanSubstitute(value) or (unsafe_count == 1 and uses == 1 and localUseBeforeEffect(self.pass.program, local, body))) { return value; } - return try self.makeReusableForMatch(value, pending_lets); + return try self.makeReusableForMatch(value); } fn valueForInlineLocal( @@ -2482,64 +5392,236 @@ const Cloner = struct { value: Value, body: Ast.ExprId, unsafe_count: usize, - pending_lets: *std.ArrayList(PendingLet), ) Common.LowerError!Value { + if (self.knownConstructorSize(value) >= known_value_track_cap) { + return try self.boxDeepKnownValue(value); + } const uses = localUseCountInExpr(self.pass.program, local, body); if (self.valueCanSubstitute(value) or (unsafe_count == 1 and uses == 1 and localUseBeforeEffect(self.pass.program, local, body))) { return value; } - return try self.makeReusableForMatch(value, pending_lets); + return try self.makeReusableForMatch(value); + } + + /// Reported size for a known value that exhausts the size work budget: a + /// value too large to measure counts as effectively unbounded. Reporting a + /// value this large (rather than a truncated count) errs the inline + /// recursion guard toward declining — a call whose size reads as the cap is + /// never strictly smaller than an active frame, so it takes the residual + /// (boxed) call — which is the safe direction for a depth/size measure. + const known_constructor_size_cap: usize = std.math.maxInt(usize); + + /// Total work budget for measuring one known value's constructor size. + /// Substitution shares one value union across every use site, so a value + /// built by a recursively-constructed chain is reached by combinatorially + /// many paths; an unmemoized count re-descends the shared substructure and + /// need not terminate in bounded time. The count spends one shared budget + /// per node visit and reports the cap when it runs out. See design.md + /// "Core Principles" on bounded post-check walks. + const known_constructor_size_work_budget: u32 = 4096; + + /// Count the constructor nodes (tag, record, tuple, nominal, callable) in a + /// known value, treating opaque `expr` leaves as zero. This is the measure + /// the inline recursion guard shrinks: a call re-entering a function already + /// on the inline stack is admitted only when its known-constructor arguments + /// are strictly smaller, so inlining an adapter step's `Iter.next` on its + /// inner iterator (one layer smaller) makes progress and terminates. + fn knownConstructorSize(self: *Cloner, value: Value) usize { + var budget: u32 = known_constructor_size_work_budget; + return self.knownConstructorSizeBudgeted(value, &budget); + } + + fn knownConstructorSizeBudgeted(self: *Cloner, value: Value, budget: *u32) usize { + if (budget.* == 0) return known_constructor_size_cap; + budget.* -= 1; + return switch (value) { + .expr => 0, + .static_data_candidate => |candidate| self.knownConstructorSizeBudgeted(candidate.runtime.*, budget), + .tag => |tag| blk: { + var count: usize = 1; + for (tag.payloads) |payload| count += self.knownConstructorSizeBudgeted(payload, budget); + break :blk count; + }, + .record => |record| blk: { + var count: usize = 1; + for (record.fields) |field| count += self.knownConstructorSizeBudgeted(field.value, budget); + break :blk count; + }, + .tuple => |tuple| blk: { + var count: usize = 1; + for (tuple.items) |item| count += self.knownConstructorSizeBudgeted(item, budget); + break :blk count; + }, + .nominal => |nominal| 1 + self.knownConstructorSizeBudgeted(nominal.backing.*, budget), + .callable => |callable| blk: { + var count: usize = 1; + for (callable.captures) |capture| count += self.knownConstructorSizeBudgeted(capture.value, budget); + break :blk count; + }, + }; + } + + /// Resolve an expression to its known value through the current + /// substitution environment without emitting anything. Used only to measure + /// a call's known-constructor size for the inline recursion guard; returns + /// null when the expression carries no known constructor here. + fn peekKnownValue(self: *Cloner, expr_id: Ast.ExprId) ?Value { + const expr = self.pass.program.getExpr(expr_id); + return switch (expr.data) { + .local => |local| blk: { + if (self.subst.get(local)) |value| break :blk value; + if (self.binderIdentityOf(local)) |identity| { + if (self.binder_subst.get(identity)) |value| break :blk value; + } + break :blk null; + }, + .field_access => |field| blk: { + const receiver = self.peekKnownValue(field.receiver) orelse break :blk null; + break :blk fieldFromValue(self.pass.program, receiver, field.field); + }, + .tuple_access => |access| blk: { + const receiver = self.peekKnownValue(access.tuple) orelse break :blk null; + break :blk itemFromValue(receiver, access.elem_index); + }, + .static_data_candidate => |candidate| self.peekKnownValue(candidate.runtime_expr), + else => null, + }; + } + + fn argsKnownConstructorSize(self: *Cloner, span: Ast.Span(Ast.ExprId)) usize { + var total: usize = 0; + const args = self.pass.program.exprSpan(span); + for (0..args.len) |index| { + const arg = GuardedList.at(args, index); + if (self.peekKnownValue(arg)) |value| total += self.knownConstructorSize(value); + } + return total; + } + + fn captureOperandsKnownConstructorSize(self: *Cloner, span: Ast.Span(Ast.CaptureOperand)) usize { + var total: usize = 0; + const operands = self.pass.program.captureOperandSpan(span); + for (0..operands.len) |index| { + const operand = GuardedList.at(operands, index); + if (self.peekKnownValue(operand.value)) |value| total += self.knownConstructorSize(value); + } + return total; } + /// Reported unsafe-leaf count for a known value that exhausts the work + /// budget: a value too large to scan counts as having many unsafe leaves. + /// Reporting the cap (rather than a truncated count) errs every consumer + /// toward reuse — a count above one fails the `unsafe_count == 1` + /// single-substitution conditions, so the value is bound to a local and + /// evaluated once instead of duplicated — which is the safe direction: it + /// can never drop or reorder an effect a truncated count would have missed. + const unsafe_leaf_count_cap: usize = std.math.maxInt(usize); + + /// Total work budget for scanning one known value's unsafe leaves. Shared + /// substructure makes an unmemoized scan re-descend combinatorially many + /// paths, so the scan spends one shared budget per node visit and reports + /// the cap when it runs out. See design.md "Core Principles" on bounded + /// post-check walks. + const unsafe_leaf_count_work_budget: u32 = 4096; + fn unsafeLeafCount(self: *Cloner, value: Value) usize { + var budget: u32 = unsafe_leaf_count_work_budget; + return self.unsafeLeafCountBudgeted(value, &budget); + } + + fn unsafeLeafCountBudgeted(self: *Cloner, value: Value, budget: *u32) usize { + if (budget.* == 0) return unsafe_leaf_count_cap; + budget.* -= 1; return switch (value) { .expr => |expr| if (self.exprCanSubstitute(expr)) 0 else 1, + .static_data_candidate => |candidate| self.unsafeLeafCountBudgeted(candidate.runtime.*, budget), .tag => |tag| blk: { var count: usize = 0; - for (tag.payloads) |payload| count += self.unsafeLeafCount(payload); + for (tag.payloads) |payload| count += self.unsafeLeafCountBudgeted(payload, budget); break :blk count; }, .record => |record| blk: { var count: usize = 0; - for (record.fields) |field| count += self.unsafeLeafCount(field.value); + for (record.fields) |field| count += self.unsafeLeafCountBudgeted(field.value, budget); break :blk count; }, .tuple => |tuple| blk: { var count: usize = 0; - for (tuple.items) |item| count += self.unsafeLeafCount(item); + for (tuple.items) |item| count += self.unsafeLeafCountBudgeted(item, budget); break :blk count; }, - .nominal => |nominal| self.unsafeLeafCount(nominal.backing.*), + .nominal => |nominal| self.unsafeLeafCountBudgeted(nominal.backing.*, budget), .callable => |callable| blk: { var count: usize = 0; - for (callable.captures) |capture| count += self.unsafeLeafCount(capture.value); + for (callable.captures) |capture| count += self.unsafeLeafCountBudgeted(capture.value, budget); break :blk count; }, }; } - fn makeReusableForMatch(self: *Cloner, value: Value, pending_lets: *std.ArrayList(PendingLet)) Common.LowerError!Value { + /// Total work budget for making one value reuse-safe. A known value is not + /// always a small finite tree: substitution shares one value union across + /// every use site, so a value built by a recursively-constructed chain (an + /// iterator wrapped around itself through many map layers) is a compact + /// graph reached by combinatorially many distinct paths, and this walk + /// probes each visited node with `valueCanSubstitute` — itself a full + /// sub-walk — so its cost is the node count times that probe and grows far + /// past any per-level depth. The walk spends one shared budget per node + /// visit and, when it runs out, keeps the remaining sub-value materialized + /// as-is instead of continuing to rewrite it. See design.md "Core + /// Principles" on bounded post-check walks. + /// + /// Keeping a sub-value as-is declines the single-evaluation rewrite for it, + /// the same conservative direction the substitution check takes on its own + /// exhaustion: the values large enough to exhaust this budget are the deep + /// constructor chains of recursive iterator construction, whose leaves are + /// pure structural components, so leaving them un-rewritten at worst + /// recomputes a pure leaf and never drops or reorders an effect. + const make_reusable_work_budget: u32 = 4096; + + fn makeReusableForMatch(self: *Cloner, value: Value) Common.LowerError!Value { + var budget: u32 = make_reusable_work_budget; + return try self.makeReusableForMatchBudgeted(value, &budget); + } + + fn makeReusableForMatchBudgeted(self: *Cloner, value: Value, budget: *u32) Common.LowerError!Value { + if (budget.* == 0) return value; + budget.* -= 1; if (self.valueCanSubstitute(value)) return value; return switch (value) { .expr => |expr| blk: { const ty = self.pass.program.getExpr(expr).ty; const local = try self.pass.program.addLocal(self.pass.symbols.fresh(), ty); - try pending_lets.append(self.pass.allocator, .{ + try self.pending.append(self.pass.allocator, .{ .local = local, .ty = ty, .value = expr, + .marks = self.effect_marks, }); break :blk Value{ .expr = try self.addExpr(.{ .ty = ty, .data = .{ .local = local }, }) }; }, + .static_data_candidate => |candidate| blk: { + const local = try self.pass.program.addLocal(self.pass.symbols.fresh(), candidate.ty); + try self.pending.append(self.pass.allocator, .{ + .local = local, + .ty = candidate.ty, + .value = try self.materialize(value), + .marks = self.effect_marks, + }); + break :blk Value{ .expr = try self.addExpr(.{ + .ty = candidate.ty, + .data = .{ .local = local }, + }) }; + }, .tag => |tag| blk: { const payloads = try self.pass.arena.allocator().alloc(Value, tag.payloads.len); for (tag.payloads, 0..) |payload, index| { - payloads[index] = try self.makeReusableForMatch(payload, pending_lets); + payloads[index] = try self.makeReusableForMatchBudgeted(payload, budget); } break :blk Value{ .tag = .{ .ty = tag.ty, @@ -2552,7 +5634,7 @@ const Cloner = struct { for (record.fields, 0..) |field, index| { fields[index] = .{ .name = field.name, - .value = try self.makeReusableForMatch(field.value, pending_lets), + .value = try self.makeReusableForMatchBudgeted(field.value, budget), }; } break :blk Value{ .record = .{ @@ -2563,7 +5645,7 @@ const Cloner = struct { .tuple => |tuple| blk: { const items = try self.pass.arena.allocator().alloc(Value, tuple.items.len); for (tuple.items, 0..) |item, index| { - items[index] = try self.makeReusableForMatch(item, pending_lets); + items[index] = try self.makeReusableForMatchBudgeted(item, budget); } break :blk Value{ .tuple = .{ .ty = tuple.ty, @@ -2572,7 +5654,7 @@ const Cloner = struct { }, .nominal => |nominal| blk: { const backing = try self.pass.arena.allocator().create(Value); - backing.* = try self.makeReusableForMatch(nominal.backing.*, pending_lets); + backing.* = try self.makeReusableForMatchBudgeted(nominal.backing.*, budget); break :blk Value{ .nominal = .{ .ty = nominal.ty, .backing = backing, @@ -2583,7 +5665,7 @@ const Cloner = struct { for (callable.captures, 0..) |capture, index| { captures[index] = .{ .id = capture.id, - .value = try self.makeReusableForMatch(capture.value, pending_lets), + .value = try self.makeReusableForMatchBudgeted(capture.value, budget), }; } break :blk Value{ .callable = .{ @@ -2595,15 +5677,17 @@ const Cloner = struct { }; } - fn wrapPendingLets(self: *Cloner, body: Value, pending_lets: []const PendingLet) Common.LowerError!Value { - if (pending_lets.len == 0) return body; - - const ty = valueType(self.pass.program, body); - var result = try self.materialize(body); - var index = pending_lets.len; - while (index > 0) { + /// Emit the pending bindings created at or after `start` as a let chain + /// around `expr`, oldest outermost so evaluation order is preserved, and + /// drop them from the stack. + fn flushPendingSince(self: *Cloner, start: usize, expr: Ast.ExprId) Common.LowerError!Ast.ExprId { + if (self.pending.items.len <= start) return expr; + const ty = self.pass.program.getExpr(expr).ty; + var result = expr; + var index = self.pending.items.len; + while (index > start) { index -= 1; - const pending = pending_lets[index]; + const pending = self.pending.items[index]; const pat = try self.pass.program.addPat(.{ .ty = pending.ty, .data = .{ .bind = pending.local }, @@ -2614,7 +5698,51 @@ const Cloner = struct { .rest = result, } } }); } - return .{ .expr = result }; + self.pending.shrinkRetainingCapacity(start); + return result; + } + + /// Emit the pending bindings created at or after `start` as let + /// statements, oldest first, and drop them from the stack. Used where a + /// statement list is being built, so the bindings dominate the statement + /// whose cloning created them and everything after it. + fn appendPendingStmtsSince(self: *Cloner, start: usize, out: *std.ArrayList(Ast.StmtId)) Common.LowerError!void { + for (self.pending.items[start..]) |pending| { + const pat = try self.pass.program.addPat(.{ + .ty = pending.ty, + .data = .{ .bind = pending.local }, + }); + try out.append(self.pass.allocator, try self.addStmt(.{ .let_ = .{ + .pat = pat, + .value = pending.value, + } })); + } + self.pending.shrinkRetainingCapacity(start); + } + + /// Resolve the pending bindings a construct created while producing + /// `body`. A structured value whose bindings are all effect-free + /// computations, created in a region that has emitted no effect, keeps + /// its structure: the bindings stay pending and the region boundary + /// emits them, where they still dominate every leaf reference and cross + /// only effect-free evaluation. Anything else pins the value here — it + /// is materialized and wrapped so evaluation order and count stay + /// exactly as written. + fn resolvePending(self: *Cloner, start: usize, body: Value) Common.LowerError!Value { + if (self.pending.items.len <= start) return body; + if (body != .expr) { + var delegatable = true; + for (self.pending.items[start..]) |pending| { + if (pending.marks != self.region_entry_marks or + !exprHasNoObservableEffect(self.pass.program, self.pass.fn_effect_free, pending.value, false)) + { + delegatable = false; + break; + } + } + if (delegatable) return body; + } + return .{ .expr = try self.flushPendingSince(start, try self.materialize(body)) }; } fn cloneCaseOfCaseValue( @@ -2623,11 +5751,8 @@ const Cloner = struct { scrutinee_expr: Ast.ExprId, outer_branches_span: Ast.Span(Ast.Branch), ) Common.LowerError!?Value { + const pending_entry = self.pending.items.len; const scrutinee_data = self.pass.program.getExpr(scrutinee_expr).data; - const inner_match = switch (scrutinee_data) { - .match_ => |match| match, - else => return null, - }; const outer_branches = self.pass.program.branchSpan(outer_branches_span); for (0..outer_branches.len) |branch_index| { @@ -2635,27 +5760,103 @@ const Cloner = struct { if (branch.guard != null) return null; } - const inner_branches = try GuardedList.dupe(self.pass.allocator, Ast.Branch, self.pass.program.branchSpan(inner_match.branches)); - defer self.pass.allocator.free(inner_branches); + switch (scrutinee_data) { + .match_ => |inner_match| { + const inner_branches = try GuardedList.dupe(self.pass.allocator, Ast.Branch, self.pass.program.branchSpan(inner_match.branches)); + defer self.pass.allocator.free(inner_branches); + + var rewritten = try self.pass.allocator.alloc(Ast.Branch, inner_branches.len); + defer self.pass.allocator.free(rewritten); + + for (inner_branches, 0..) |inner_branch, index| { + // Each rewritten branch flushes every pending binding it + // creates, so it is its own region. + const pending_start = self.pending.items.len; + const saved_entry_marks = self.region_entry_marks; + self.region_entry_marks = self.effect_marks; + defer self.region_entry_marks = saved_entry_marks; + const change_start = self.changes.items.len; + try self.shadowPatLocals(inner_branch.pat); + const inner_value = try self.cloneExprValue(inner_branch.body); + const outer_value = (try self.distributeMatchOverValue(ty, inner_value, outer_branches_span)) orelse { + self.restore(change_start); + self.pending.shrinkRetainingCapacity(pending_entry); + return null; + }; + rewritten[index] = .{ + .pat = inner_branch.pat, + .guard = inner_branch.guard, + .body = try self.flushPendingSince(pending_start, try self.materialize(outer_value)), + }; + self.restore(change_start); + } + + return .{ .expr = try self.addExpr(.{ .ty = ty, .data = .{ .match_ = .{ + .scrutinee = inner_match.scrutinee, + .branches = try self.pass.program.addBranchSpan(rewritten), + .comptime_site = inner_match.comptime_site, + } } }) }; + }, + .if_ => |inner_if| { + const inner_branches = try GuardedList.dupe(self.pass.allocator, Ast.IfBranch, self.pass.program.ifBranchSpan(inner_if.branches)); + defer self.pass.allocator.free(inner_branches); + + var rewritten = try self.pass.allocator.alloc(Ast.IfBranch, inner_branches.len); + defer self.pass.allocator.free(rewritten); + + for (inner_branches, 0..) |inner_branch, index| { + // Each rewritten branch flushes every pending binding it + // creates, so it is its own region. + const pending_start = self.pending.items.len; + const saved_entry_marks = self.region_entry_marks; + self.region_entry_marks = self.effect_marks; + defer self.region_entry_marks = saved_entry_marks; + const inner_value = try self.cloneExprValue(inner_branch.body); + const outer_value = (try self.distributeMatchOverValue(ty, inner_value, outer_branches_span)) orelse { + self.pending.shrinkRetainingCapacity(pending_entry); + return null; + }; + rewritten[index] = .{ + .cond = inner_branch.cond, + .body = try self.flushPendingSince(pending_start, try self.materialize(outer_value)), + }; + } - var rewritten = try self.pass.allocator.alloc(Ast.Branch, inner_branches.len); - defer self.pass.allocator.free(rewritten); + const pending_start = self.pending.items.len; + const saved_entry_marks = self.region_entry_marks; + self.region_entry_marks = self.effect_marks; + defer self.region_entry_marks = saved_entry_marks; + const else_value = try self.cloneExprValue(inner_if.final_else); + const outer_else = (try self.distributeMatchOverValue(ty, else_value, outer_branches_span)) orelse { + self.pending.shrinkRetainingCapacity(pending_entry); + return null; + }; + const final_else = try self.flushPendingSince(pending_start, try self.materialize(outer_else)); - for (inner_branches, 0..) |inner_branch, index| { - const inner_value = try self.cloneExprValue(inner_branch.body); - const outer_value = (try self.simplifyKnownMatchValue(inner_value, outer_branches_span)) orelse return null; - rewritten[index] = .{ - .pat = inner_branch.pat, - .guard = inner_branch.guard, - .body = try self.materialize(outer_value), - }; + return .{ .expr = try self.addExpr(.{ .ty = ty, .data = .{ .if_ = .{ + .branches = try self.pass.program.addIfBranchSpan(rewritten), + .final_else = final_else, + } } }) }; + }, + else => return null, } + } - return .{ .expr = try self.addExpr(.{ .ty = ty, .data = .{ .match_ = .{ - .scrutinee = inner_match.scrutinee, - .branches = try self.pass.program.addBranchSpan(rewritten), - .comptime_site = inner_match.comptime_site, - } } }) }; + /// Collapse an outer match against one inner-branch result: a known + /// constructor selects its arm directly, and a branch-built result + /// distributes recursively so the arms land where the constructors are + /// known. + fn distributeMatchOverValue( + self: *Cloner, + ty: Type.TypeId, + inner_value: Value, + outer_branches_span: Ast.Span(Ast.Branch), + ) Common.LowerError!?Value { + if (try self.selectKnownMatchValue(inner_value, outer_branches_span, true)) |value| return value; + return switch (inner_value) { + .expr => |expr| try self.cloneCaseOfCaseValue(ty, expr, outer_branches_span), + else => null, + }; } fn inlineCallableCallValue( @@ -2664,8 +5865,12 @@ const Cloner = struct { callable: CallableValue, args_span: Ast.Span(Ast.ExprId), ) Common.LowerError!Value { + var callable_call_size: usize = 0; + for (callable.captures) |capture| callable_call_size += self.knownConstructorSize(capture.value); + callable_call_size += self.argsKnownConstructorSize(args_span); for (self.inline_stack.items) |active| { - if (active == callable.fn_id) { + if (active.fn_id != callable.fn_id) continue; + if (callable_call_size == 0 or callable_call_size >= active.known_size) { return .{ .expr = try self.addExpr(.{ .ty = ty, .data = .{ .call_value = .{ .callee = try self.materialize(.{ .callable = callable }), .args = try self.cloneExprSpan(args_span), @@ -2700,19 +5905,17 @@ const Cloner = struct { Common.invariant("callable value capture count differed from lifted function capture count"); } - var pending_lets = std.ArrayList(PendingLet).empty; - defer pending_lets.deinit(self.pass.allocator); - + const pending_start = self.pending.items.len; const change_start = self.changes.items.len; defer self.restore(change_start); - const prepared_captures = try self.pass.allocator.alloc(Value, source_captures.len); + const prepared_captures = try self.pass.allocator.alloc(Value, callable.captures.len); defer self.pass.allocator.free(prepared_captures); for (source_captures, 0..) |source_capture, index| { const id = self.pass.program.captureIdOfLocal(source_capture.local); const capture_value = callableCaptureValueForId(callable.captures, id) orelse - Common.invariant("inlined callable had no value for a capture slot"); - prepared_captures[index] = try self.makeReusableForMatch(capture_value, &pending_lets); + Common.invariant("callable value had no value for a source capture slot"); + prepared_captures[index] = try self.makeReusableForMatch(capture_value); try self.putSubst(source_capture.local, prepared_captures[index]); } @@ -2729,20 +5932,20 @@ const Cloner = struct { const prepared_args = try self.pass.allocator.alloc(Value, arg_values.len); defer self.pass.allocator.free(prepared_args); for (source_args, arg_values, 0..) |source_arg, arg_value, index| { - prepared_args[index] = try self.valueForInlineLocal(source_arg.local, arg_value, body, unsafe_count, &pending_lets); + prepared_args[index] = try self.valueForInlineLocal(source_arg.local, arg_value, body, unsafe_count); } - try self.inline_stack.append(self.pass.allocator, callable.fn_id); + try self.inline_stack.append(self.pass.allocator, .{ .fn_id = callable.fn_id, .known_size = callable_call_size }); defer { const popped = self.inline_stack.pop() orelse Common.invariant("call-pattern inline stack underflow"); - if (popped != callable.fn_id) Common.invariant("call-pattern inline stack was corrupted"); + if (popped.fn_id != callable.fn_id) Common.invariant("call-pattern inline stack was corrupted"); } for (source_args, prepared_args) |source_arg, arg_value| { try self.putSubst(source_arg.local, arg_value); } - return try self.wrapPendingLets(try self.cloneExprValue(body), pending_lets.items); + return try self.resolvePending(pending_start, try self.cloneExprValue(body)); } fn inlineDirectCallValue( @@ -2752,8 +5955,12 @@ const Cloner = struct { captures_span: Ast.Span(Ast.CaptureOperand), original_expr: Ast.ExprId, ) Common.LowerError!Value { + const direct_call_size = self.argsKnownConstructorSize(args_span) + self.captureOperandsKnownConstructorSize(captures_span); for (self.inline_stack.items) |active| { - if (active == callee) return .{ .expr = try self.cloneExprPlain(original_expr) }; + if (active.fn_id != callee) continue; + if (direct_call_size == 0 or direct_call_size >= active.known_size) { + return .{ .expr = try self.cloneExprPlain(original_expr) }; + } } const source_fn = self.pass.program.getFn(callee); @@ -2770,9 +5977,7 @@ const Cloner = struct { defer self.pass.allocator.free(args); if (source_args.len != args.len) Common.invariant("direct call arity differed from lifted function arity"); - var pending_lets = std.ArrayList(PendingLet).empty; - defer pending_lets.deinit(self.pass.allocator); - + const pending_start = self.pending.items.len; const change_start = self.changes.items.len; defer self.restore(change_start); @@ -2790,7 +5995,10 @@ const Cloner = struct { const capture_values = try self.pass.allocator.alloc(CaptureValue, operands.len); defer self.pass.allocator.free(capture_values); for (operands, 0..) |operand, index| { - capture_values[index] = .{ .id = operand.id, .value = try self.cloneExprValue(operand.value) }; + capture_values[index] = .{ + .id = operand.id, + .value = try self.cloneExprValue(operand.value), + }; } const arg_values = try self.pass.allocator.alloc(Value, args.len); @@ -2808,20 +6016,20 @@ const Cloner = struct { for (captures, 0..) |capture, index| { const id = self.pass.program.captureIdOfLocal(capture.local); const capture_value = callableCaptureValueForId(capture_values, id) orelse - Common.invariant("inlined direct call had no value for a capture slot"); - prepared_captures[index] = try self.valueForInlineLocal(capture.local, capture_value, body, unsafe_count, &pending_lets); + Common.invariant("direct call had no value for a source capture slot"); + prepared_captures[index] = try self.valueForInlineLocal(capture.local, capture_value, body, unsafe_count); } const prepared_args = try self.pass.allocator.alloc(Value, arg_values.len); defer self.pass.allocator.free(prepared_args); for (source_args, arg_values, 0..) |source_arg, arg_value, index| { - prepared_args[index] = try self.valueForInlineLocal(source_arg.local, arg_value, body, unsafe_count, &pending_lets); + prepared_args[index] = try self.valueForInlineLocal(source_arg.local, arg_value, body, unsafe_count); } - try self.inline_stack.append(self.pass.allocator, callee); + try self.inline_stack.append(self.pass.allocator, .{ .fn_id = callee, .known_size = direct_call_size }); defer { const popped = self.inline_stack.pop() orelse Common.invariant("call-pattern inline stack underflow"); - if (popped != callee) Common.invariant("call-pattern inline stack was corrupted"); + if (popped.fn_id != callee) Common.invariant("call-pattern inline stack was corrupted"); } for (captures, prepared_captures) |capture, capture_value| { @@ -2831,7 +6039,7 @@ const Cloner = struct { try self.putSubst(source_arg.local, arg_value); } - return try self.wrapPendingLets(try self.cloneExprValue(body), pending_lets.items); + return try self.resolvePending(pending_start, try self.cloneExprValue(body)); } fn bindPatToValue(self: *Cloner, pat_id: Ast.PatId, value: Value) Common.LowerError!bool { @@ -2848,29 +6056,63 @@ const Cloner = struct { return true; }, .record => |fields_span| { - const record = recordFromValue(value) orelse return false; const fields = self.pass.program.recordDestructSpan(fields_span); - for (0..fields.len) |index| { - const field = GuardedList.at(fields, index); - const field_value = fieldFromRecord(record, field.name) orelse return false; - if (!try self.bindPatToValue(field.pattern, field_value)) return false; + switch (value) { + .record, .nominal => { + const record = recordFromValue(value) orelse return false; + for (0..fields.len) |index| { + const field = GuardedList.at(fields, index); + const field_value = fieldFromRecord(self.pass.program, record, field.name) orelse return false; + if (!try self.bindPatToValue(field.pattern, field_value)) return false; + } + }, + .expr => |receiver| { + if (!canReadFieldsFromExpr(self.pass.program, receiver)) return false; + for (0..fields.len) |index| { + const field = GuardedList.at(fields, index); + const field_ty = self.pass.program.getPat(field.pattern).ty; + const field_expr = try self.addExpr(.{ .ty = field_ty, .data = .{ .field_access = .{ + .receiver = receiver, + .field = field.name, + } } }); + if (!try self.bindPatToValue(field.pattern, .{ .expr = field_expr })) return false; + } + }, + else => return false, } return true; }, .tuple => |items_span| { - const tuple = tupleFromValue(value) orelse return false; const pats = self.pass.program.patSpan(items_span); - if (pats.len != tuple.items.len) return false; - for (0..pats.len) |index| { - const child_pat = GuardedList.at(pats, index); - const child_value = tuple.items[index]; - if (!try self.bindPatToValue(child_pat, child_value)) return false; + switch (value) { + .tuple, .nominal => { + const tuple = tupleFromValue(value) orelse return false; + if (pats.len != tuple.items.len) return false; + for (0..pats.len) |index| { + const child_pat = GuardedList.at(pats, index); + const child_value = tuple.items[index]; + if (!try self.bindPatToValue(child_pat, child_value)) return false; + } + }, + .expr => |receiver| { + if (!canReadFieldsFromExpr(self.pass.program, receiver)) return false; + for (0..pats.len) |index| { + const child_pat = GuardedList.at(pats, index); + const item_ty = self.pass.program.getPat(child_pat).ty; + const item_expr = try self.addExpr(.{ .ty = item_ty, .data = .{ .tuple_access = .{ + .tuple = receiver, + .elem_index = @as(u32, @intCast(index)), + } } }); + if (!try self.bindPatToValue(child_pat, .{ .expr = item_expr })) return false; + } + }, + else => return false, } return true; }, .tag => |tag_pat| { const tag = tagFromValue(value) orelse return false; - if (tag.name != tag_pat.name) return false; + if (!self.pass.program.names.tagLabelTextEql(tag.name, tag_pat.name)) return false; const pats = self.pass.program.patSpan(tag_pat.payloads); if (pats.len != tag.payloads.len) return false; for (0..pats.len) |index| { @@ -2904,6 +6146,61 @@ const Cloner = struct { return try self.bindPatToValue(pat_id, value); } + /// Record an identity substitution for a local bound by a retained + /// source pattern: the pattern's body resolves the local to the + /// pattern's own runtime binding, never to an outer substitution that + /// bound the same local id in another clone of this code. + fn shadowLocal(self: *Cloner, local: Ast.LocalId) Common.LowerError!void { + const ty = self.pass.program.getLocal(local).ty; + try self.putSubst(local, .{ .expr = try self.addExpr(.{ .ty = ty, .data = .{ .local = local } }) }); + } + + fn shadowPatLocals(self: *Cloner, pat_id: Ast.PatId) Common.LowerError!void { + const pat = self.pass.program.getPat(pat_id); + switch (pat.data) { + .bind => |local| try self.shadowLocal(local), + .wildcard, + .int_lit, + .dec_lit, + .frac_f32_lit, + .frac_f64_lit, + .str_lit, + => {}, + .as => |as| { + try self.shadowPatLocals(as.pattern); + try self.shadowLocal(as.local); + }, + .record => |fields| { + const record_fields = self.pass.program.recordDestructSpan(fields); + for (0..record_fields.len) |index| { + try self.shadowPatLocals(GuardedList.at(record_fields, index).pattern); + } + }, + .tuple => |items| { + const children = self.pass.program.patSpan(items); + for (0..children.len) |index| try self.shadowPatLocals(GuardedList.at(children, index)); + }, + .tag => |tag| { + const children = self.pass.program.patSpan(tag.payloads); + for (0..children.len) |index| try self.shadowPatLocals(GuardedList.at(children, index)); + }, + .nominal => |backing| try self.shadowPatLocals(backing), + .list => |list| { + const children = self.pass.program.patSpan(list.patterns); + for (0..children.len) |index| try self.shadowPatLocals(GuardedList.at(children, index)); + if (list.rest) |rest| { + if (rest.pattern) |rest_pattern| try self.shadowPatLocals(rest_pattern); + } + }, + .str_pattern => |str| { + const steps = self.pass.program.strPatternStepSpan(str.steps); + for (0..steps.len) |index| { + if (GuardedList.at(steps, index).capture) |capture| try self.shadowPatLocals(capture); + } + }, + } + } + fn clonePat(self: *Cloner, pat_id: Ast.PatId) Allocator.Error!Ast.PatId { const pat = self.pass.program.getPat(pat_id); const data: Ast.PatData = switch (pat.data) { @@ -2958,7 +6255,12 @@ const Cloner = struct { }; } - fn cloneStmt(self: *Cloner, stmt_id: Ast.StmtId) Common.LowerError!Ast.StmtId { + /// Clone one statement. A binding statement whose value's opaque leaves + /// can all be named dissolves instead: the caller drains the pending + /// bindings at this statement's position — the same computations in the + /// same order — and the bound name keeps its structured value for the + /// rest of the block. Returns null for a dissolved statement. + fn cloneStmt(self: *Cloner, stmt_id: Ast.StmtId) Common.LowerError!?Ast.StmtId { const saved_loc = self.current_loc; defer self.current_loc = saved_loc; const saved_region = self.current_region; @@ -2970,11 +6272,37 @@ const Cloner = struct { const stmt = self.pass.program.getStmt(stmt_id); return try self.addStmt(switch (stmt) { - .uninitialized => |pat| .{ .uninitialized = try self.clonePat(pat) }, + .uninitialized => |pat| blk: { + try self.shadowPatLocals(pat); + break :blk .{ .uninitialized = try self.clonePat(pat) }; + }, .let_ => |let_| blk: { const value = try self.cloneExprValue(let_.value); const value_expr = try self.materialize(value); - _ = try self.bindPatToReusableValue(let_.pat, value); + if (try self.bindPatToReusableValue(let_.pat, value)) { + break :blk .{ .let_ = .{ + .pat = try self.clonePat(let_.pat), + .value = value_expr, + .recursive = let_.recursive, + .comptime_site = let_.comptime_site, + } }; + } + const pat = self.pass.program.getPat(let_.pat); + const self_referential = switch (pat.data) { + .bind => |local| localUseCountInExpr(self.pass.program, local, let_.value) != 0, + else => let_.recursive, + }; + if (!self_referential) { + // The drained bindings sit exactly where the statement + // sat, so no evaluation moves and no gate is needed. + const change_before = self.changes.items.len; + const pending_before = self.pending.items.len; + const reusable = try self.makeReusableForMatch(value); + if (try self.bindPatToValue(let_.pat, reusable)) return null; + self.restore(change_before); + self.pending.shrinkRetainingCapacity(pending_before); + } + try self.shadowPatLocals(let_.pat); break :blk .{ .let_ = .{ .pat = try self.clonePat(let_.pat), .value = value_expr, @@ -3003,15 +6331,18 @@ const Cloner = struct { return try self.pass.program.addExprSpan(values); } - /// Clone a keyed capture operand span, preserving each operand's CaptureId - /// and cloning its supplying expression. fn cloneCaptureOperandSpan(self: *Cloner, span: Ast.Span(Ast.CaptureOperand)) Common.LowerError!Ast.Span(Ast.CaptureOperand) { const source = try GuardedList.dupe(self.pass.allocator, Ast.CaptureOperand, self.pass.program.captureOperandSpan(span)); defer self.pass.allocator.free(source); const operands = try self.pass.allocator.alloc(Ast.CaptureOperand, source.len); defer self.pass.allocator.free(operands); - for (source, 0..) |operand, index| operands[index] = .{ .id = operand.id, .value = try self.cloneExpr(operand.value) }; + for (source, 0..) |operand, index| { + operands[index] = .{ + .id = operand.id, + .value = try self.cloneExpr(operand.value), + }; + } return try self.pass.program.addCaptureOperandSpan(operands); } @@ -3062,11 +6393,14 @@ const Cloner = struct { const values = try self.pass.allocator.alloc(Ast.Branch, source.len); defer self.pass.allocator.free(values); for (source, 0..) |branch, index| { + const change_start = self.changes.items.len; + try self.shadowPatLocals(branch.pat); values[index] = .{ .pat = try self.clonePat(branch.pat), .guard = if (branch.guard) |guard| try self.cloneExpr(guard) else null, .body = try self.cloneExpr(branch.body), }; + self.restore(change_start); } return try self.pass.program.addBranchSpan(values); } @@ -3089,6 +6423,10 @@ const Cloner = struct { fn materialize(self: *Cloner, value: Value) Common.LowerError!Ast.ExprId { switch (value) { .expr => |expr| return expr, + .static_data_candidate => |candidate| return try self.addExpr(.{ .ty = candidate.ty, .data = .{ .static_data_candidate = .{ + .static_data = candidate.static_data, + .runtime_expr = try self.materialize(candidate.runtime.*), + } } }), .tag => |tag| { const payloads = try self.pass.allocator.alloc(Ast.ExprId, tag.payloads.len); defer self.pass.allocator.free(payloads); @@ -3293,10 +6631,6 @@ const Cloner = struct { Common.invariant("callable value capture count differed from specialized function capture count"); } - // Build one operand per capture slot, keyed by the slot's CaptureId. The - // supplying value is looked up by id (not position), so a specialized - // callee whose slots differ in order from the source still gets each - // capture's correct value. const operands = try self.pass.allocator.alloc(Ast.CaptureOperand, captures.len); defer self.pass.allocator.free(operands); for (captures, 0..) |capture, index| { @@ -3318,8 +6652,6 @@ const Cloner = struct { } } }); } - /// Look up the cloned value supplying capture `id` in a callable value's - /// keyed captures. fn callableCaptureValueForId(values: []const CaptureValue, id: check.CheckedModule.CaptureId) ?Value { for (values) |capture_value| { if (capture_value.id == id) return capture_value.value; @@ -3348,16 +6680,30 @@ const Cloner = struct { .nominal, => true, .expr, + .static_data_candidate, .callable, => false, }; - if (subst_binder) if (self.pass.program.getLocal(local).binder) |binder| { - const previous_binder = self.binder_subst.get(binder); + if (subst_binder) if (self.binderIdentityOf(local)) |identity| { + const previous_binder = self.binder_subst.get(identity); try self.changes.append(self.pass.allocator, .{ - .key = .{ .binder = binder }, + .key = .{ .binder = identity }, .previous = previous_binder, }); - try self.binder_subst.put(binder, value); + try self.binder_subst.put(identity, value); + }; + } + + /// Identity a local's binder-scoped substitution is keyed by: the pattern + /// binder together with the digest of the local's monomorphic type. Two + /// locals that share a binder but were monomorphized at different types are + /// distinct bindings and must not read one another's substitution. + fn binderIdentityOf(self: *Cloner, local: Ast.LocalId) ?BinderIdentity { + const local_data = self.pass.program.getLocal(local); + const binder = local_data.binder orelse return null; + return .{ + .binder = binder, + .digest = self.pass.program.types.typeDigest(&self.pass.program.names, local_data.ty), }; } @@ -3374,11 +6720,11 @@ const Cloner = struct { _ = self.subst.remove(local); } }, - .binder => |binder| { + .binder => |identity| { if (change.previous) |previous| { - self.binder_subst.putAssumeCapacity(binder, previous); + self.binder_subst.putAssumeCapacity(identity, previous); } else { - _ = self.binder_subst.remove(binder); + _ = self.binder_subst.remove(identity); } }, } @@ -3387,6 +6733,34 @@ const Cloner = struct { } fn addExpr(self: *Cloner, expr: Ast.Expr) Allocator.Error!Ast.ExprId { + // Track emissions that carry an observable effect (host effects enter + // through calls; low-level ops are data operations apart from the + // crash op and the process-seed read). Pending bindings created after + // such an emission must not move ahead of it. + switch (expr.data) { + .call_proc => |call| { + const effect_free = switch (call.callee) { + .lifted => |fn_id| blk: { + const raw = @intFromEnum(fn_id); + break :blk raw < self.pass.fn_effect_free.len and self.pass.fn_effect_free[raw]; + }, + .func => false, + }; + if (!effect_free) self.effect_marks += 1; + }, + .call_value, + .crash, + .dbg, + .expect, + .expect_err, + .comptime_exhaustiveness_failed, + => self.effect_marks += 1, + .low_level => |call| switch (call.op) { + .crash, .dict_pseudo_seed => self.effect_marks += 1, + else => {}, + }, + else => {}, + } const saved_loc = self.pass.program.current_loc; defer self.pass.program.current_loc = saved_loc; const saved_region = self.pass.program.current_region; @@ -3414,6 +6788,138 @@ fn localExpr(program: *const Ast.Program, expr_id: Ast.ExprId) ?Ast.LocalId { }; } +fn exprCallsFn(program: *const Ast.Program, expr_id: Ast.ExprId, fn_id: Ast.FnId) bool { + return switch (program.getExpr(expr_id).data) { + .local, + .unit, + .int_lit, + .frac_f32_lit, + .frac_f64_lit, + .dec_lit, + .str_lit, + .bytes_lit, + .crash, + .comptime_exhaustiveness_failed, + .uninitialized, + .uninitialized_payload, + => false, + .fn_ref => |fn_ref| captureOperandSpanCallsFn(program, fn_ref.captures, fn_id), + .list, + .tuple, + => |items| exprSpanCallsFn(program, items, fn_id), + .record => |fields| fieldExprSpanCallsFn(program, fields, fn_id), + .tag => |tag| exprSpanCallsFn(program, tag.payloads, fn_id), + .static_data_candidate => |candidate| exprCallsFn(program, candidate.runtime_expr, fn_id), + .nominal, + .dbg, + .expect, + => |child| exprCallsFn(program, child, fn_id), + .return_ => |ret| exprCallsFn(program, ret.value, fn_id), + .expect_err => |expect_err| exprCallsFn(program, expect_err.msg, fn_id), + .comptime_branch_taken => |taken| exprCallsFn(program, taken.body, fn_id), + .let_ => |let_| exprCallsFn(program, let_.value, fn_id) or exprCallsFn(program, let_.rest, fn_id), + .lambda, + .def_ref, + .fn_def, + => Common.invariant("pre-lift function expression reached recursive-call scan"), + .call_value => |call| exprCallsFn(program, call.callee, fn_id) or exprSpanCallsFn(program, call.args, fn_id), + .call_proc => |call| { + if (Ast.localDirectCallee(call)) |callee| { + if (callee == fn_id) return true; + } + return exprSpanCallsFn(program, call.args, fn_id) or captureOperandSpanCallsFn(program, call.captures, fn_id); + }, + .low_level => |call| exprSpanCallsFn(program, call.args, fn_id), + .field_access => |field| exprCallsFn(program, field.receiver, fn_id), + .tuple_access => |access| exprCallsFn(program, access.tuple, fn_id), + .structural_eq => |eq| exprCallsFn(program, eq.lhs, fn_id) or exprCallsFn(program, eq.rhs, fn_id), + .structural_hash => |h| exprCallsFn(program, h.value, fn_id) or exprCallsFn(program, h.hasher, fn_id), + .match_ => |match| exprCallsFn(program, match.scrutinee, fn_id) or branchSpanCallsFn(program, match.branches, fn_id), + .if_ => |if_| ifBranchSpanCallsFn(program, if_.branches, fn_id) or exprCallsFn(program, if_.final_else, fn_id), + .block => |block| stmtSpanCallsFn(program, block.statements, fn_id) or exprCallsFn(program, block.final_expr, fn_id), + .loop_ => |loop| exprSpanCallsFn(program, loop.initial_values, fn_id) or exprCallsFn(program, loop.body, fn_id), + .break_ => |maybe| if (maybe) |value| exprCallsFn(program, value, fn_id) else false, + .continue_ => |continue_| exprSpanCallsFn(program, continue_.values, fn_id), + .if_initialized_payload => |payload_switch| exprCallsFn(program, payload_switch.cond, fn_id) or + exprCallsFn(program, payload_switch.initialized, fn_id) or + exprCallsFn(program, payload_switch.uninitialized, fn_id), + .try_sequence => |sequence| exprCallsFn(program, sequence.try_expr, fn_id) or exprCallsFn(program, sequence.ok_body, fn_id), + .try_record_sequence => |sequence| exprCallsFn(program, sequence.try_expr, fn_id) or exprCallsFn(program, sequence.ok_body, fn_id), + }; +} + +fn exprSpanCallsFn(program: *const Ast.Program, span: Ast.Span(Ast.ExprId), fn_id: Ast.FnId) bool { + const exprs = program.exprSpan(span); + for (0..exprs.len) |index| { + const expr = GuardedList.at(exprs, index); + if (exprCallsFn(program, expr, fn_id)) return true; + } + return false; +} + +fn captureOperandSpanCallsFn(program: *const Ast.Program, span: Ast.Span(Ast.CaptureOperand), fn_id: Ast.FnId) bool { + const operands = program.captureOperandSpan(span); + for (0..operands.len) |index| { + const operand = GuardedList.at(operands, index); + if (exprCallsFn(program, operand.value, fn_id)) return true; + } + return false; +} + +fn fieldExprSpanCallsFn(program: *const Ast.Program, span: Ast.Span(Ast.FieldExpr), fn_id: Ast.FnId) bool { + const field_exprs = program.fieldExprSpan(span); + for (0..field_exprs.len) |index| { + const field = GuardedList.at(field_exprs, index); + if (exprCallsFn(program, field.value, fn_id)) return true; + } + return false; +} + +fn branchSpanCallsFn(program: *const Ast.Program, span: Ast.Span(Ast.Branch), fn_id: Ast.FnId) bool { + const branches = program.branchSpan(span); + for (0..branches.len) |index| { + const branch = GuardedList.at(branches, index); + if (branch.guard) |guard| { + if (exprCallsFn(program, guard, fn_id)) return true; + } + if (exprCallsFn(program, branch.body, fn_id)) return true; + } + return false; +} + +fn ifBranchSpanCallsFn(program: *const Ast.Program, span: Ast.Span(Ast.IfBranch), fn_id: Ast.FnId) bool { + const branches = program.ifBranchSpan(span); + for (0..branches.len) |index| { + const branch = GuardedList.at(branches, index); + if (exprCallsFn(program, branch.cond, fn_id)) return true; + if (exprCallsFn(program, branch.body, fn_id)) return true; + } + return false; +} + +fn stmtSpanCallsFn(program: *const Ast.Program, span: Ast.Span(Ast.StmtId), fn_id: Ast.FnId) bool { + const statements = program.stmtSpan(span); + for (0..statements.len) |index| { + const stmt = GuardedList.at(statements, index); + if (stmtCallsFn(program, stmt, fn_id)) return true; + } + return false; +} + +fn stmtCallsFn(program: *const Ast.Program, stmt_id: Ast.StmtId, fn_id: Ast.FnId) bool { + return switch (program.getStmt(stmt_id)) { + .let_ => |let_| exprCallsFn(program, let_.value, fn_id), + .expr, + .expect, + .dbg, + => |expr| exprCallsFn(program, expr, fn_id), + .return_ => |ret| exprCallsFn(program, ret.value, fn_id), + .uninitialized, + .crash, + => false, + }; +} + fn exprContainsReturn(program: *const Ast.Program, expr_id: Ast.ExprId) bool { return switch (program.getExpr(expr_id).data) { .local, @@ -3446,6 +6952,7 @@ fn exprContainsReturn(program: *const Ast.Program, expr_id: Ast.ExprId) bool { return false; }, .tag => |tag| exprSpanContainsReturn(program, tag.payloads), + .static_data_candidate => |candidate| exprContainsReturn(program, candidate.runtime_expr), .nominal, .dbg, .expect, @@ -3561,6 +7068,7 @@ fn localUseCountInExpr(program: *const Ast.Program, local: Ast.LocalId, expr_id: break :blk count; }, .tag => |tag| localUseCountInExprSpan(program, local, tag.payloads), + .static_data_candidate => |candidate| localUseCountInExpr(program, local, candidate.runtime_expr), .nominal, .dbg, .expect, @@ -3664,6 +7172,147 @@ const LocalUseScan = struct { found_after_effect: bool = false, }; +/// Whether evaluating an expression can produce an observable effect. Host +/// effects enter through procedure calls, so a call carries an effect unless +/// its target's whole body is effect-free; low-level ops are data operations +/// apart from the crash op and the process-seed read. Divergence through +/// checked arithmetic is not an effect: within one straight-line region a +/// crash commutes with pure evaluation. +/// `allow_control` distinguishes the two users: classifying a whole function +/// body tolerates control transfers (they stay inside the function), while a +/// value being discarded or moved must not carry one. +fn exprHasNoObservableEffect(program: *const Ast.Program, fn_effect_free: []const bool, expr_id: Ast.ExprId, allow_control: bool) bool { + const expr = program.getExpr(expr_id); + return switch (expr.data) { + .local, + .unit, + .int_lit, + .frac_f32_lit, + .frac_f64_lit, + .dec_lit, + .str_lit, + .bytes_lit, + .uninitialized, + .uninitialized_payload, + .def_ref, + .fn_def, + => true, + .fn_ref => |fn_ref| captureOperandSpanHasNoObservableEffect(program, fn_effect_free, fn_ref.captures, allow_control), + .list, + .tuple, + => |items| exprSpanHasNoObservableEffect(program, fn_effect_free, items, allow_control), + .record => |fields| blk: { + const field_exprs = program.fieldExprSpan(fields); + for (0..field_exprs.len) |index| { + const field = GuardedList.at(field_exprs, index); + if (!exprHasNoObservableEffect(program, fn_effect_free, field.value, allow_control)) break :blk false; + } + break :blk true; + }, + .tag => |tag| exprSpanHasNoObservableEffect(program, fn_effect_free, tag.payloads, allow_control), + .static_data_candidate => true, + .nominal => |child| exprHasNoObservableEffect(program, fn_effect_free, child, allow_control), + .field_access => |field| exprHasNoObservableEffect(program, fn_effect_free, field.receiver, allow_control), + .tuple_access => |access| exprHasNoObservableEffect(program, fn_effect_free, access.tuple, allow_control), + .structural_eq => |eq| exprHasNoObservableEffect(program, fn_effect_free, eq.lhs, allow_control) and + exprHasNoObservableEffect(program, fn_effect_free, eq.rhs, allow_control), + .structural_hash => |h| exprHasNoObservableEffect(program, fn_effect_free, h.value, allow_control) and + exprHasNoObservableEffect(program, fn_effect_free, h.hasher, allow_control), + .low_level => |call| switch (call.op) { + .crash, .dict_pseudo_seed => false, + else => exprSpanHasNoObservableEffect(program, fn_effect_free, call.args, allow_control), + }, + .call_proc => |call| blk: { + const callee = switch (call.callee) { + .lifted => |fn_id| fn_id, + .func => break :blk false, + }; + const raw = @intFromEnum(callee); + if (raw >= fn_effect_free.len or !fn_effect_free[raw]) break :blk false; + break :blk exprSpanHasNoObservableEffect(program, fn_effect_free, call.args, allow_control) and + captureOperandSpanHasNoObservableEffect(program, fn_effect_free, call.captures, allow_control); + }, + .let_ => |let_| exprHasNoObservableEffect(program, fn_effect_free, let_.value, allow_control) and + exprHasNoObservableEffect(program, fn_effect_free, let_.rest, allow_control), + .if_ => |if_| blk: { + const branches = program.ifBranchSpan(if_.branches); + for (0..branches.len) |index| { + const branch = GuardedList.at(branches, index); + if (!exprHasNoObservableEffect(program, fn_effect_free, branch.cond, allow_control)) break :blk false; + if (!exprHasNoObservableEffect(program, fn_effect_free, branch.body, allow_control)) break :blk false; + } + break :blk exprHasNoObservableEffect(program, fn_effect_free, if_.final_else, allow_control); + }, + .match_ => |match| blk: { + if (!exprHasNoObservableEffect(program, fn_effect_free, match.scrutinee, allow_control)) break :blk false; + const branches = program.branchSpan(match.branches); + for (0..branches.len) |index| { + const branch = GuardedList.at(branches, index); + if (branch.guard) |guard| { + if (!exprHasNoObservableEffect(program, fn_effect_free, guard, allow_control)) break :blk false; + } + if (!exprHasNoObservableEffect(program, fn_effect_free, branch.body, allow_control)) break :blk false; + } + break :blk true; + }, + .block => |block| blk: { + const statements = program.stmtSpan(block.statements); + for (0..statements.len) |index| { + const stmt_id = GuardedList.at(statements, index); + const no_effect = switch (program.getStmt(stmt_id)) { + .let_ => |let_| exprHasNoObservableEffect(program, fn_effect_free, let_.value, allow_control), + .expr => |stmt_expr| exprHasNoObservableEffect(program, fn_effect_free, stmt_expr, allow_control), + .uninitialized => true, + .return_ => |ret| allow_control and exprHasNoObservableEffect(program, fn_effect_free, ret.value, allow_control), + .expect, .dbg, .crash => false, + }; + if (!no_effect) break :blk false; + } + break :blk exprHasNoObservableEffect(program, fn_effect_free, block.final_expr, allow_control); + }, + // A loop contains its own back edges, so its body may transfer + // control regardless of the caller's tolerance. + .loop_ => |loop| exprSpanHasNoObservableEffect(program, fn_effect_free, loop.initial_values, allow_control) and + exprHasNoObservableEffect(program, fn_effect_free, loop.body, true), + .break_ => |maybe| allow_control and + (if (maybe) |value| exprHasNoObservableEffect(program, fn_effect_free, value, allow_control) else true), + .continue_ => |continue_| allow_control and exprSpanHasNoObservableEffect(program, fn_effect_free, continue_.values, allow_control), + .if_initialized_payload => |payload_switch| exprHasNoObservableEffect(program, fn_effect_free, payload_switch.cond, allow_control) and + exprHasNoObservableEffect(program, fn_effect_free, payload_switch.initialized, allow_control) and + exprHasNoObservableEffect(program, fn_effect_free, payload_switch.uninitialized, allow_control), + .comptime_branch_taken => |taken| exprHasNoObservableEffect(program, fn_effect_free, taken.body, allow_control), + .return_ => |ret| allow_control and exprHasNoObservableEffect(program, fn_effect_free, ret.value, allow_control), + .lambda, + .call_value, + .crash, + .dbg, + .expect, + .expect_err, + .comptime_exhaustiveness_failed, + .try_sequence, + .try_record_sequence, + => false, + }; +} + +fn exprSpanHasNoObservableEffect(program: *const Ast.Program, fn_effect_free: []const bool, span: Ast.Span(Ast.ExprId), allow_control: bool) bool { + const exprs = program.exprSpan(span); + for (0..exprs.len) |index| { + const expr = GuardedList.at(exprs, index); + if (!exprHasNoObservableEffect(program, fn_effect_free, expr, allow_control)) return false; + } + return true; +} + +fn captureOperandSpanHasNoObservableEffect(program: *const Ast.Program, fn_effect_free: []const bool, span: Ast.Span(Ast.CaptureOperand), allow_control: bool) bool { + const operands = program.captureOperandSpan(span); + for (0..operands.len) |index| { + const operand = GuardedList.at(operands, index); + if (!exprHasNoObservableEffect(program, fn_effect_free, operand.value, allow_control)) return false; + } + return true; +} + fn localUseBeforeEffect(program: *const Ast.Program, local: Ast.LocalId, expr_id: Ast.ExprId) bool { var scan: LocalUseScan = .{}; scanLocalUseInExpr(program, local, expr_id, &scan); @@ -3705,6 +7354,7 @@ fn scanLocalUseInExpr(program: *const Ast.Program, local: Ast.LocalId, expr_id: } }, .tag => |tag| scanLocalUseInExprSpan(program, local, tag.payloads, scan), + .static_data_candidate => |candidate| scanLocalUseInExpr(program, local, candidate.runtime_expr, scan), .nominal => |child| scanLocalUseInExpr(program, local, child, scan), .return_ => |ret| { scanLocalUseInExpr(program, local, ret.value, scan); @@ -3907,6 +7557,7 @@ fn shapeType(shape: Shape) Type.TypeId { fn valueType(program: *const Ast.Program, value: Value) Type.TypeId { return switch (value) { .expr => |expr| program.getExpr(expr).ty, + .static_data_candidate => |candidate| candidate.ty, .tag => |tag| tag.ty, .record => |record| record.ty, .tuple => |tuple| tuple.ty, @@ -3940,7 +7591,12 @@ fn shapeEql(program: *const Ast.Program, lhs: Shape, rhs: Shape) bool { .any => |lhs_ty| sameType(program, lhs_ty, rhs.any), .tag => |lhs_tag| blk: { const rhs_tag = rhs.tag; - if (!sameType(program, lhs_tag.ty, rhs_tag.ty) or lhs_tag.name != rhs_tag.name or lhs_tag.payloads.len != rhs_tag.payloads.len) break :blk false; + if (!sameType(program, lhs_tag.ty, rhs_tag.ty) or + !program.names.tagLabelTextEql(lhs_tag.name, rhs_tag.name) or + lhs_tag.payloads.len != rhs_tag.payloads.len) + { + break :blk false; + } for (lhs_tag.payloads, rhs_tag.payloads) |lhs_payload, rhs_payload| { if (!shapeEql(program, lhs_payload, rhs_payload)) break :blk false; } @@ -3950,7 +7606,11 @@ fn shapeEql(program: *const Ast.Program, lhs: Shape, rhs: Shape) bool { const rhs_record = rhs.record; if (!sameType(program, lhs_record.ty, rhs_record.ty) or lhs_record.fields.len != rhs_record.fields.len) break :blk false; for (lhs_record.fields, rhs_record.fields) |lhs_field, rhs_field| { - if (lhs_field.name != rhs_field.name or !shapeEql(program, lhs_field.shape, rhs_field.shape)) break :blk false; + if (!program.names.recordFieldLabelTextEql(lhs_field.name, rhs_field.name) or + !shapeEql(program, lhs_field.shape, rhs_field.shape)) + { + break :blk false; + } } break :blk true; }, @@ -3983,32 +7643,45 @@ fn shapeEql(program: *const Ast.Program, lhs: Shape, rhs: Shape) bool { } fn shapeMatchesValue(program: *const Ast.Program, shape: Shape, value: Value) bool { + const structural_value = switch (value) { + .static_data_candidate => |candidate| candidate.runtime.*, + else => value, + }; return switch (shape) { .any => true, .tag => |tag| blk: { - const value_tag = switch (value) { + const value_tag = switch (structural_value) { .tag => |value_tag| value_tag, else => break :blk false, }; - if (!sameType(program, tag.ty, value_tag.ty) or tag.name != value_tag.name or tag.payloads.len != value_tag.payloads.len) break :blk false; + if (!sameType(program, tag.ty, value_tag.ty) or + !program.names.tagLabelTextEql(tag.name, value_tag.name) or + tag.payloads.len != value_tag.payloads.len) + { + break :blk false; + } for (tag.payloads, value_tag.payloads) |payload_shape, payload_value| { if (!shapeMatchesValue(program, payload_shape, payload_value)) break :blk false; } break :blk true; }, .record => |record| blk: { - const value_record = switch (value) { + const value_record = switch (structural_value) { .record => |value_record| value_record, else => break :blk false, }; if (!sameType(program, record.ty, value_record.ty) or record.fields.len != value_record.fields.len) break :blk false; for (record.fields, value_record.fields) |field_shape, field_value| { - if (field_shape.name != field_value.name or !shapeMatchesValue(program, field_shape.shape, field_value.value)) break :blk false; + if (!program.names.recordFieldLabelTextEql(field_shape.name, field_value.name) or + !shapeMatchesValue(program, field_shape.shape, field_value.value)) + { + break :blk false; + } } break :blk true; }, .tuple => |tuple| blk: { - const value_tuple = switch (value) { + const value_tuple = switch (structural_value) { .tuple => |value_tuple| value_tuple, else => break :blk false, }; @@ -4019,14 +7692,14 @@ fn shapeMatchesValue(program: *const Ast.Program, shape: Shape, value: Value) bo break :blk true; }, .nominal => |nominal| blk: { - const value_nominal = switch (value) { + const value_nominal = switch (structural_value) { .nominal => |value_nominal| value_nominal, else => break :blk false, }; break :blk sameType(program, nominal.ty, value_nominal.ty) and shapeMatchesValue(program, nominal.backing.*, value_nominal.backing.*); }, .callable => |callable| blk: { - const value_callable = switch (value) { + const value_callable = switch (structural_value) { .callable => |value_callable| value_callable, else => break :blk false, }; @@ -4051,22 +7724,22 @@ fn callableTargetMatches(program: *const Ast.Program, expected: Ast.FnId, actual return Mono.fnTemplateIdentityEql(expected_source, actual_source); } -fn fieldFromValue(value: Value, name: names.RecordFieldNameId) ?Value { +fn fieldFromValue(program: *const Ast.Program, value: Value, name: names.RecordFieldNameId) ?Value { const record = recordFromValue(value) orelse return null; - return fieldFromRecord(record, name); + return fieldFromRecord(program, record, name); } -fn fieldFromRecord(record: RecordValue, name: names.RecordFieldNameId) ?Value { +fn fieldFromRecord(program: *const Ast.Program, record: RecordValue, name: names.RecordFieldNameId) ?Value { for (record.fields) |field| { - if (field.name == name) return field.value; + if (program.names.recordFieldLabelTextEql(field.name, name)) return field.value; } return null; } -fn recordPatField(fields: anytype, name: names.RecordFieldNameId) ?Ast.PatId { +fn recordPatField(program: *const Ast.Program, fields: anytype, name: names.RecordFieldNameId) ?Ast.PatId { for (0..fields.len) |index| { const field = GuardedList.at(fields, index); - if (field.name == name) return field.pattern; + if (program.names.recordFieldLabelTextEql(field.name, name)) return field.pattern; } return null; } @@ -4079,6 +7752,7 @@ fn itemFromValue(value: Value, index: u32) ?Value { fn tagFromValue(value: Value) ?TagValue { return switch (value) { + .static_data_candidate => |candidate| tagFromValue(candidate.runtime.*), .tag => |tag| tag, .nominal => |nominal| tagFromValue(nominal.backing.*), else => null, @@ -4087,6 +7761,7 @@ fn tagFromValue(value: Value) ?TagValue { fn recordFromValue(value: Value) ?RecordValue { return switch (value) { + .static_data_candidate => |candidate| recordFromValue(candidate.runtime.*), .record => |record| record, .nominal => |nominal| recordFromValue(nominal.backing.*), else => null, @@ -4095,6 +7770,7 @@ fn recordFromValue(value: Value) ?RecordValue { fn tupleFromValue(value: Value) ?TupleValue { return switch (value) { + .static_data_candidate => |candidate| tupleFromValue(candidate.runtime.*), .tuple => |tuple| tuple, .nominal => |nominal| tupleFromValue(nominal.backing.*), else => null, diff --git a/src/postcheck/solved_inline.zig b/src/postcheck/solved_inline.zig index 7f46c6637f1..7ba29dd8f47 100644 --- a/src/postcheck/solved_inline.zig +++ b/src/postcheck/solved_inline.zig @@ -227,6 +227,7 @@ const WrapperAnalyzer = struct { return true; }, .tag => |tag| self.exprSpanReadsOnlyArgs(tag.payloads, args), + .static_data_candidate => |candidate| self.exprReadsOnlyArgs(candidate.runtime_expr, args), .nominal, .dbg, .expect, @@ -328,6 +329,7 @@ const WrapperAnalyzer = struct { } }, .tag => |tag| try self.visitSpanCallees(tag.payloads), + .static_data_candidate => |candidate| try self.visitBodyCallees(candidate.runtime_expr), .nominal, .dbg, .expect, diff --git a/src/postcheck/solved_lir_lower.zig b/src/postcheck/solved_lir_lower.zig index 815b03750ee..26a6ef1eee7 100644 --- a/src/postcheck/solved_lir_lower.zig +++ b/src/postcheck/solved_lir_lower.zig @@ -316,6 +316,7 @@ const Lowerer = struct { const_type_map: std.AutoHashMap(Type.TypeId, const_store.ConstTypeId), mono_const_type_map: std.AutoHashMap(MonoType.TypeId, const_store.ConstTypeId), callable_source_fn_map: std.AutoHashMap(Type.TypeId, SolvedType.TypeVarId), + static_data_map: []?LIR.StaticDataId, symbols: Common.SymbolGen, local_map: []?LIR.LocalId, typed_local_map: std.AutoHashMap(TypedLiftedLocal, LIR.LocalId), @@ -353,6 +354,10 @@ const Lowerer = struct { errdefer allocator.free(comptime_site_map); @memset(comptime_site_map, null); + const static_data_map = try allocator.alloc(?LIR.StaticDataId, solved.lifted.static_data_values.len()); + errdefer allocator.free(static_data_map); + @memset(static_data_map, null); + return .{ .allocator = allocator, .solved = solved, @@ -383,6 +388,7 @@ const Lowerer = struct { .const_type_map = std.AutoHashMap(Type.TypeId, const_store.ConstTypeId).init(allocator), .mono_const_type_map = std.AutoHashMap(MonoType.TypeId, const_store.ConstTypeId).init(allocator), .callable_source_fn_map = std.AutoHashMap(Type.TypeId, SolvedType.TypeVarId).init(allocator), + .static_data_map = static_data_map, .symbols = .{ .next = solved.lifted.next_symbol }, .local_map = local_map, .typed_local_map = std.AutoHashMap(TypedLiftedLocal, LIR.LocalId).init(allocator), @@ -403,6 +409,7 @@ const Lowerer = struct { self.const_type_map.deinit(); self.mono_const_type_map.deinit(); self.callable_source_fn_map.deinit(); + self.allocator.free(self.static_data_map); self.layout_owner_types.deinit(); self.type_layouts.deinit(); self.runtime_schema_requests.deinit(self.allocator); @@ -438,6 +445,7 @@ const Lowerer = struct { self.const_type_map.deinit(); self.mono_const_type_map.deinit(); self.callable_source_fn_map.deinit(); + self.allocator.free(self.static_data_map); self.layout_owner_types.deinit(); self.type_layouts.deinit(); self.runtime_schema_requests.deinit(self.allocator); @@ -459,6 +467,7 @@ const Lowerer = struct { self.local_map = &.{}; self.typed_local_map = std.AutoHashMap(TypedLiftedLocal, LIR.LocalId).init(self.allocator); self.local_types = std.AutoHashMap(LIR.LocalId, Type.TypeId).init(self.allocator); + self.static_data_map = &.{}; self.comptime_site_map = &.{}; self.loop_stack = .empty; self.folded_map_matches = .empty; @@ -1264,6 +1273,102 @@ const Lowerer = struct { return id; } + fn lirStaticDataFor( + self: *Lowerer, + id: Common.StaticDataId, + layout_idx: layout.Idx, + plan: LirProgram.ConstPlanId, + ) Common.LowerError!LIR.StaticDataId { + const raw = @intFromEnum(id); + if (raw >= self.static_data_map.len) Common.invariant("static data candidate id exceeded lifted static data table"); + if (self.static_data_map[raw]) |existing| return existing; + + const source = GuardedList.at(self.solved.lifted.static_data_values.unsafeRawItemsForView(), raw); + const result_id: LIR.StaticDataId = @enumFromInt(@as(u32, @intCast(self.result.static_data_values.items.len))); + try self.result.static_data_values.append(self.allocator, .{ + .const_locator = source.const_locator, + .node = source.node, + .checked_type = source.checked_type, + .layout_idx = layout_idx, + .plan = plan, + }); + self.static_data_map[raw] = result_id; + return result_id; + } + + fn constPlanNeedsStaticData(self: *Lowerer, plan_id: LirProgram.ConstPlanId, layout_idx: layout.Idx) bool { + const layout_data = self.result.layouts.getLayout(layout_idx); + if (self.result.layouts.layoutSize(layout_data) == 0) return false; + return switch (self.result.const_plans.items[@intFromEnum(plan_id)]) { + .pending => Common.invariant("pending const plan reached static-data selection"), + .zst, + .scalar, + => false, + .str, + .list, + .box, + .fn_value, + .erased_fn, + => true, + .tuple => |plans| self.aggregatePlanNeedsStaticData(plans, layout_idx), + .record => |plans| self.aggregatePlanNeedsStaticData(plans, layout_idx), + .tag_union => |variants| self.tagPlanNeedsStaticData(variants, layout_idx), + .named => |named| switch (layout_data.tag) { + .box, + .box_of_zst, + => true, + else => self.constPlanNeedsStaticData(named.backing, layout_idx), + }, + }; + } + + fn aggregatePlanNeedsStaticData(self: *Lowerer, plans: []const LirProgram.ConstPlanId, layout_idx: layout.Idx) bool { + if (plans.len == 0) return false; + const layout_data = self.result.layouts.getLayout(layout_idx); + return switch (layout_data.tag) { + .zst => false, + .box, + .box_of_zst, + .list, + .list_of_zst, + .closure, + .erased_callable, + => true, + .struct_ => blk: { + const struct_idx = layout_data.getStruct().idx; + for (plans, 0..) |plan, index| { + const field_layout = self.result.layouts.getStructFieldLayoutByOriginalIndex(struct_idx, @intCast(index)); + if (self.constPlanNeedsStaticData(plan, field_layout)) break :blk true; + } + break :blk false; + }, + else => Common.invariant("aggregate const plan reached a non-aggregate layout"), + }; + } + + fn tagPlanNeedsStaticData(self: *Lowerer, variants: []const LirProgram.ConstTagVariant, layout_idx: layout.Idx) bool { + const layout_data = self.result.layouts.getLayout(layout_idx); + switch (layout_data.tag) { + .zst, .scalar => return false, + .box, .box_of_zst => return true, + .tag_union => {}, + else => Common.invariant("tag const plan reached a non-tag layout"), + } + const data = self.result.layouts.getTagUnionData(layout_data.getTagUnion().idx); + const layout_variants = self.result.layouts.getTagUnionVariants(data); + if (layout_variants.len != variants.len) Common.invariant("tag const plan variant count differed from layout variant count"); + for (variants, 0..) |variant, index| { + if (variant.payloads.len == 0) continue; + const payload_layout = layout_variants.get(@intCast(index)).payload_layout; + if (variant.payloads.len == 1) { + if (self.constPlanNeedsStaticData(variant.payloads[0], payload_layout)) return true; + } else if (self.aggregatePlanNeedsStaticData(variant.payloads, payload_layout)) { + return true; + } + } + return false; + } + fn buildConstPlan(self: *Lowerer, ty: Type.TypeId) Common.LowerError!LirProgram.ConstPlan { return switch (self.types.get(ty)) { .primitive => |primitive| switch (primitive) { @@ -1379,6 +1484,9 @@ const Lowerer = struct { .module = try self.result.const_type_names.internModuleIdentity(self.solved.lifted.names.moduleIdentityBytes(def.module)), .type_name = try self.result.const_type_names.internTypeName(self.solved.lifted.names.typeNameText(def.type_name)), .source_decl = def.source_decl, + .generated = def.generated, + .iterator_representation = @enumFromInt(@intFromEnum(def.iterator_representation)), + .iterator_depth = def.iterator_depth, }; } @@ -1982,6 +2090,25 @@ const Lowerer = struct { return try self.lowerExprIntoAtType(ret_local, expr_id, ret_ty, ret_stmt); } + fn lowerStaticDataCandidateInto( + self: *Lowerer, + target: LIR.LocalId, + candidate: Mono.StaticDataCandidate, + ty: Type.TypeId, + next: LIR.CFStmtId, + ) Common.LowerError!LIR.CFStmtId { + const layout_idx = self.result.store.getLocal(target).layout_idx; + const plan = try self.constPlanOfType(ty); + if (self.constPlanNeedsStaticData(plan, layout_idx)) { + return try self.result.store.addCFStmt(.{ .assign_literal = .{ + .target = target, + .value = .{ .static_data = try self.lirStaticDataFor(candidate.static_data, layout_idx, plan) }, + .next = next, + } }); + } + return try self.lowerExprIntoAtType(target, candidate.runtime_expr, ty, next); + } + fn lowerExprInto( self: *Lowerer, target: LIR.LocalId, @@ -2045,6 +2172,7 @@ const Lowerer = struct { } }); }, .uninitialized, .uninitialized_payload => next, + .static_data_candidate => |candidate| try self.lowerStaticDataCandidateInto(target, candidate, expr_ty, next), .list => |items| try self.lowerListInto(target, items, next), .tuple => |items| try self.lowerTupleInto(target, items, next), .record => |fields| try self.lowerRecordInto(target, expr_ty, fields, next), @@ -2133,6 +2261,7 @@ const Lowerer = struct { return switch (expr_data.data) { .local => |local| try self.lowerLocalInto(target, local, ty, next), + .static_data_candidate => |candidate| try self.lowerStaticDataCandidateInto(target, candidate, ty, next), .list => |items| try self.lowerListIntoAtType(target, ty, items, next), .tuple => |items| try self.lowerTupleIntoAtType(target, ty, items, next), .record => |fields| try self.lowerRecordInto(target, ty, fields, next), @@ -3280,20 +3409,36 @@ const Lowerer = struct { if (solved_args.len != lifted_args.len) Common.invariant("direct Lambda Mono function arity changed after Lambda Solved"); if (arg_locals.len != lifted_args.len) Common.invariant("inline call argument count differed from function arity"); + // An inlined parameter binds a fresh local for the duration of the + // inlined body and shadows any enclosing binding of the same lifted + // local. A closure nested in the callee reuses the callee's parameter + // ids as its capture ids, so when the enclosing function being lowered + // captures those same ids, the parameter binding must win over the + // capture binding. Shadow both `local_map` and the capture table so a + // capture-record read (`captureBindingForLocal`) cannot resolve a + // parameter reference back to the enclosing capture. const saved = try self.allocator.alloc(?LIR.LocalId, lifted_args.len); defer self.allocator.free(saved); + const saved_captures = try self.allocator.alloc(?CaptureBinding, lifted_args.len); + defer self.allocator.free(saved_captures); + try self.captures.ensureUnusedCapacity(@intCast(lifted_args.len)); for (0..lifted_args.len) |i| { const arg = GuardedList.at(lifted_args, i); saved[i] = self.local_map[@intFromEnum(arg.local)]; + saved_captures[i] = self.captures.get(arg.local); } for (0..lifted_args.len) |i| { const arg = GuardedList.at(lifted_args, i); self.local_map[@intFromEnum(arg.local)] = arg_locals[i]; + _ = self.captures.remove(arg.local); } defer { for (0..lifted_args.len) |i| { const arg = GuardedList.at(lifted_args, i); self.local_map[@intFromEnum(arg.local)] = saved[i]; + if (saved_captures[i]) |capture| { + self.captures.putAssumeCapacity(arg.local, capture); + } } } @@ -5897,9 +6042,6 @@ const Lowerer = struct { ) Common.LowerError!LIR.CFStmtId { const source_variants = self.types.fnVariantSpan(source_span); const target_variants = self.types.fnVariantSpan(target_span); - if (source_variants.len != target_variants.len) { - Common.invariant("callable boundary saw different source and target variant counts"); - } if (self.isZstLocal(source)) return try self.assignZst(target, next); const branches = try self.allocator.alloc(LIR.CFSwitchBranch, source_variants.len); @@ -6528,6 +6670,11 @@ const Lowerer = struct { return switch (owner) { .fields, .parse_tag_union_spec, + // `Iter`/`Stream` instances of one item type share a nominal but + // carry different step captures per chain, so their layouts must be + // distinguished by backing rather than by nominal identity alone. + .iter, + .stream, => true, else => false, }; @@ -6627,13 +6774,13 @@ const Lowerer = struct { return true; } - fn typeContainsCallable(self: *Lowerer, ty: Type.TypeId) Common.LowerError!bool { + fn typeContainsErasedFn(self: *Lowerer, ty: Type.TypeId) Common.LowerError!bool { var visited = std.AutoHashMap(Type.TypeId, void).init(self.allocator); defer visited.deinit(); - return try self.typeContainsCallableInner(ty, &visited); + return try self.typeContainsErasedFnInner(ty, &visited); } - fn typeContainsCallableInner( + fn typeContainsErasedFnInner( self: *Lowerer, ty: Type.TypeId, visited: *std.AutoHashMap(Type.TypeId, void), @@ -6642,16 +6789,16 @@ const Lowerer = struct { try visited.put(ty, {}); return switch (self.types.get(ty)) { - .callable, .erased_fn => true, - .primitive, .zst, .erased_capture_ptr => false, - .list => |elem| try self.typeContainsCallableInner(elem, visited), - .box => |elem| try self.typeContainsCallableInner(elem, visited), - .tuple => |items| try self.typeSpanContainsCallable(items, visited), + .erased_fn => true, + .callable, .primitive, .zst, .erased_capture_ptr => false, + .list => |elem| try self.typeContainsErasedFnInner(elem, visited), + .box => |elem| try self.typeContainsErasedFnInner(elem, visited), + .tuple => |items| try self.typeSpanContainsErasedFn(items, visited), .record => |fields| blk: { const field_span = self.types.fieldSpan(fields); for (0..field_span.len) |index| { const field = GuardedList.at(field_span, index); - if (try self.typeContainsCallableInner(field.ty, visited)) break :blk true; + if (try self.typeContainsErasedFnInner(field.ty, visited)) break :blk true; } break :blk false; }, @@ -6659,7 +6806,7 @@ const Lowerer = struct { const field_span = self.types.captureFieldSpan(fields); for (0..field_span.len) |index| { const field = GuardedList.at(field_span, index); - if (try self.typeContainsCallableInner(field.ty, visited)) break :blk true; + if (try self.typeContainsErasedFnInner(field.ty, visited)) break :blk true; } break :blk false; }, @@ -6667,18 +6814,18 @@ const Lowerer = struct { const tag_span = self.types.tagSpan(tags); for (0..tag_span.len) |index| { const tag = GuardedList.at(tag_span, index); - if (try self.typeSpanContainsCallable(tag.payloads, visited)) break :blk true; + if (try self.typeSpanContainsErasedFn(tag.payloads, visited)) break :blk true; } break :blk false; }, .named => |named| if (named.backing) |backing| - try self.typeContainsCallableInner(backing.ty, visited) + try self.typeContainsErasedFnInner(backing.ty, visited) else false, }; } - fn typeSpanContainsCallable( + fn typeSpanContainsErasedFn( self: *Lowerer, span: Type.Span, visited: *std.AutoHashMap(Type.TypeId, void), @@ -6686,7 +6833,7 @@ const Lowerer = struct { const values = self.types.span(span); for (0..values.len) |index| { const ty = GuardedList.at(values, index); - if (try self.typeContainsCallableInner(ty, visited)) return true; + if (try self.typeContainsErasedFnInner(ty, visited)) return true; } return false; } @@ -6755,9 +6902,14 @@ const Lowerer = struct { switch (self.lowerer.types.get(ty)) { .named => |named| if (named.backing) |backing| { + const owner_backs_inline_callable = if (named.builtin_owner) |owner| + check.StaticDispatchRegistry.isIteratorOwner(owner) + else + false; if (named.kind == .@"opaque" and self.lowerer.types.get(backing.ty) == .record and - try self.lowerer.typeContainsCallable(backing.ty)) + !owner_backs_inline_callable and + try self.lowerer.typeContainsErasedFn(backing.ty)) { const node = try self.graph.reserveNode(self.lowerer.allocator); self.local_nodes[index] = node; @@ -7013,6 +7165,8 @@ const Lowerer = struct { .crypto_sha256_hasher, .crypto_blake3_digest, .crypto_blake3_hasher, + .iter, + .stream, => null, }; } @@ -7379,6 +7533,7 @@ fn cloneLiftedProgram(allocator: std.mem.Allocator, program: *const Lifted.Progr .roots = try clonedLiftedProgramList(Lifted.Root, "roots", allocator, view.roots), .layout_requests = try clonedLiftedProgramList(Lifted.LayoutRequest, "layout_requests", allocator, view.layout_requests), .runtime_schema_requests = try clonedLiftedProgramList(Lifted.RuntimeSchemaRequest, "runtime_schema_requests", allocator, view.runtime_schema_requests), + .static_data_values = try clonedLiftedProgramList(Lifted.StaticDataValue, "static_data_values", allocator, view.static_data_values), .comptime_sites = Lifted.ProgramList(Lifted.ComptimeSite, "comptime_sites").fromArrayList(try cloneComptimeSites(allocator, view.comptime_sites)), .source_files = Lifted.ProgramList([]const u8, "source_files").fromArrayList(source_files), .expr_locs = try clonedLiftedProgramList(base.SourceLoc, "expr_locs", allocator, view.expr_locs), @@ -7629,6 +7784,7 @@ fn emptySolvedProgramForTest(allocator: std.mem.Allocator) Solved.Program { .empty, .empty, .empty, + .empty, 0, ); return Solved.Program.init(allocator, lifted); diff --git a/test/cli/rocci_bird_postcheck_panic/main.roc b/test/cli/rocci_bird_postcheck_panic/main.roc new file mode 100644 index 00000000000..205e9dc3598 --- /dev/null +++ b/test/cli/rocci_bird_postcheck_panic/main.roc @@ -0,0 +1,829 @@ +app [main] { + w4: platform "./platform/main.roc", +} + +import w4.W4 +import w4.Sprite + +Model : [ + TitleScreen(TitleScreenState), + Game(GameState), + GameOver(GameOverState), +] + +main = { + init!: || init!(), + update!: |model| update!(model), +} + +init! : () => Model +init! = || { + # Lospec palette: Candy Cloud [2-BIT] Palette + palette = { + color1: 0xe6e6c0, + color2: 0xb494b7, + color3: 0x42436e, + color4: 0x26013f, + } + W4.set_palette!(palette) + + frameCount = loadRandFromDisk!() + W4.seed_rand!(frameCount) + plants = startingPlants!() + + initTitleScreen(frameCount, plants) +} + +update! : Model => Model +update! = |model| + match model { + TitleScreen(prev) => + runTitleScreen!(updateFrameCount(prev)) + + Game(prev) => + runGame!(updateFrameCount(prev)) + + GameOver(prev) => + runGameOver!(updateFrameCount(prev)) + } + +updateFrameCount = |prev| { + frameCount = prev.frameCount + 1 + { ..prev, frameCount } +} + +appendIfOk : List(a), Try(a, err) -> List(a) +appendIfOk = |items, maybe_item| + match maybe_item { + Ok(item) => List.append(items, item) + Err(_) => items + } + +subSaturatingU64 : U64, U64 -> U64 +subSaturatingU64 = |x, y| + if x < y { + 0 + } else { + x - y + } + +# ===== Title Screen ====================================== + +TitleScreenState : { + frameCount : U64, + plants : List(Plant), + rocciIdleAnim : Animation, +} + +initTitleScreen : U64, List(Plant) -> Model +initTitleScreen = |frameCount, plants| + TitleScreen({ + frameCount, + plants, + rocciIdleAnim: createRocciIdleAnim(frameCount), + }) + +runTitleScreen! : TitleScreenState => Model +runTitleScreen! = |prev| { + state = { ..prev, + rocciIdleAnim: updateAnimation(prev.frameCount, prev.rocciIdleAnim), + } + setTextColors!() + W4.text!("Rocci Bird!!!", { x: 32, y: 12 }) + W4.text!("Click to start!", { x: 24, y: 72 }) + drawGround!(groundSprite, 0) + drawPlants!(plantSpriteSheet, state.plants) + + shift = idleShift(state.frameCount, state.rocciIdleAnim) + drawAnimation!(state.rocciIdleAnim, { x: playerX, y: playerStartYPixel + shift, flags: [] }) + gamepad = W4.get_gamepad!(Player1) + mouse = W4.get_mouse!() + + start = gamepad.button1 or gamepad.up or mouse.left + + if start { + initGame!(state) + } else { + TitleScreen(state) + } +} + +# ===== Main Game ========================================= + +GameState : { + frameCount : U64, + score : U8, + maxScore : U8, + player : { + y : F32, + yVel : F32, + }, + lastFlap : Bool, + rocciFlapAnim : Animation, + pipes : List(Pipe), + lastPipeGenerated : U64, + plants : List(Plant), + lastPlantGenerated : U64, + groundX : I32, +} + +initGame! : TitleScreenState => Model +initGame! = |prev| { + frameCount = prev.frameCount + plants = prev.plants + + # Seed the randomness with number of frames since the start of the game. + # This makes the game feel like it is truly randomly seeded cause players won't always start on the same frame. + saveRandToDisk!(frameCount) + W4.seed_rand!(frameCount) + W4.tone!(flapTone) + + Game({ + frameCount, + score: 0, + maxScore: 0, + player: { + y: playerStartY, + yVel: jumpSpeed, + }, + lastPipeGenerated: frameCount, + pipes: [], + plants, + lastPlantGenerated: subSaturatingU64(frameCount, 4), + lastFlap: Bool.True, + rocciFlapAnim: createRocciFlapAnim(frameCount), + groundX: 0, + }) +} + +# Useful to throw in WolframAlpha to help calculate these: +# y = v^2 /(2a); y = -a/2*t^2 + vt; y = 20; t = 18; a > 0 +# y is max jump height in pixels. +# t is frames to reach max jump height (remember 60fps). +gravity : F32 +gravity = 0.12 + +jumpSpeed : F32 +jumpSpeed = -2.2 + +runGame! : GameState => Model +runGame! = |prev| { + gamepad = W4.get_gamepad!(Player1) + mouse = W4.get_mouse!() + + flap = gamepad.button1 or gamepad.up or mouse.left + + { yVel, nextAnim, playFlapSound } = + if !prev.lastFlap and flap and flapAllowed(prev.frameCount, prev.rocciFlapAnim) { + anim = prev.rocciFlapAnim + { + yVel: jumpSpeed, + nextAnim: { ..anim, index: 0, state: RunOnce }, + playFlapSound: Bool.True, + } + } else { + { + yVel: prev.player.yVel + gravity, + nextAnim: updateAnimation(prev.frameCount, prev.rocciFlapAnim), + playFlapSound: Bool.False, + } + } + + if playFlapSound { + W4.tone!(flapTone) + } else { + {} + } + + pipe = maybeGeneratePipe!(prev.lastPipeGenerated, prev.frameCount) + + lastPipeGenerated = + if Try.is_ok(pipe) { + prev.frameCount + } else { + prev.lastPipeGenerated + } + + pipes = + appendIfOk(updatePipes(prev.pipes), pipe) + + plant = maybeGeneratePlant!(prev.lastPlantGenerated, prev.frameCount) + + lastPlantGenerated = + if Try.is_ok(plant) { + prev.frameCount + } else { + prev.lastPlantGenerated + } + + plants = + appendIfOk(updatePlants(prev.plants), plant) + + gainPoint = U64.to_u8_wrap(List.count_if(prev.pipes, |candidatePipe| candidatePipe.x == playerX - 2)) + y = prev.player.y + yVel + score = U8.plus_saturated(prev.score, gainPoint) + state = { ..prev, + rocciFlapAnim: nextAnim, + player: { y, yVel }, + score, + maxScore: U8.max(score, prev.maxScore), + lastFlap: flap, + lastPipeGenerated, + pipes, + lastPlantGenerated, + plants, + groundX: (prev.groundX - 1) % W4.screen_width(), + } + + if gainPoint > 0 { + W4.tone!(pointTone) + } else { + {} + } + + drawPipes!(pipeSprite, state.pipes) + drawGround!(groundSprite, state.groundX) + drawPlants!(plantSpriteSheet, state.plants) + + yPixel = + I32.min(F32.to_i32_wrap(state.player.y), 134) + + collided = playerCollided!(yPixel, state.rocciFlapAnim.index) + drawAnimation!(state.rocciFlapAnim, { x: playerX, y: yPixel, flags: [] }) + drawScore!(state.score, { x: 68, y: 4 }) + + if !collided and y < 134 { + Game(state) + } else { + W4.tone!(deathTone) + + initGameOver!(state) + } +} + +# ===== Game Over ========================================= + +GameOverState : { + frameCount : U64, + score : U8, + highScore : U8, + newHighScore : Bool, + player : { + y : F32, + yVel : F32, + }, + rocciFallAnim : Animation, + highScoreAnim : Animation, + pipes : List(Pipe), + plants : List(Plant), + groundX : I32, +} + +initGameOver! : GameState => Model +initGameOver! = |prev| { + frameCount = prev.frameCount + maxScore = prev.maxScore + score = prev.score + player = prev.player + pipes = prev.pipes + plants = prev.plants + groundX = prev.groundX + + hs = loadHighScoreFromDisk!() + newHighScore = maxScore > hs + highScore = + if newHighScore { + maxScore + } else { + hs + } + saveHighScoreToDisk!(highScore) + + GameOver({ + frameCount, + score, + highScore, + newHighScore, + player, + pipes, + plants, + rocciFallAnim: createRocciFallAnim(frameCount), + highScoreAnim: createHighScoreAnim(frameCount), + groundX, + }) +} + +runGameOver! : GameOverState => Model +runGameOver! = |prev| { + yVel = prev.player.yVel + gravity + rocciFallAnim = updateAnimation(prev.frameCount, prev.rocciFallAnim) + highScoreAnim = updateAnimation(prev.frameCount, prev.highScoreAnim) + + nextY = prev.player.y + yVel + y = if nextY > 134 { + 134 + } else { + nextY + } + + state = { ..prev, + rocciFallAnim, + highScoreAnim, + player: { y, yVel }, + } + drawPipes!(pipeSprite, state.pipes) + drawGround!(groundSprite, state.groundX) + drawPlants!(plantSpriteSheet, state.plants) + + yPixel = F32.to_i32_wrap(state.player.y) + drawAnimation!(state.rocciFallAnim, { x: playerX, y: yPixel, flags: [] }) + W4.set_shape_colors!({ border: Color4, fill: Color1 }) + W4.rect!({ x: 16, y: 52, width: 136, height: 32 }) + setTextColors!() + W4.text!("Game Over!", { x: 44, y: 56 }) + W4.text!("Right to restart", { x: 20, y: 72 }) + W4.text!("Art by Luke DeVault", { x: 4, y: 151 }) + W4.set_shape_colors!({ border: Color4, fill: Color1 }) + W4.rect!({ x: 66, y: 2, width: 28, height: 12 }) + drawScore!(state.score, { x: 68, y: 4 }) + + if state.newHighScore { + drawAnimation!(state.highScoreAnim, { x: 64, y: 0, flags: [] }) + } else { + {} + } + + W4.set_shape_colors!({ border: Color4, fill: Color1 }) + W4.rect!({ x: 54, y: 18, width: 52, height: 12 }) + setTextColors!() + W4.text!("HS:", { x: 57, y: 20 }) + drawScore!(state.highScore, { x: 80, y: 20 }) + + gamepad = W4.get_gamepad!(Player1) + mouse = W4.get_mouse!() + if mouse.right or gamepad.button2 or gamepad.right { + plants = startingPlants!() + initTitleScreen(state.frameCount, plants) + } else { + GameOver(state) + } +} + +# ===== Player ============================================ + +playerStartY : F32 +playerStartY = 40 + +playerStartYPixel : I32 +playerStartYPixel = 40 + +playerX : I32 +playerX = 70 + +playerCollided! : I32, U64 => Bool +playerCollided! = |playerY, animIndex| { + if playerY >= -1 { + onScreenCollided!(playerY, animIndex) + } else { + offScreenCollided!() + } +} + +onScreenCollided! : I32, U64 => Bool +onScreenCollided! = |playerY, animIndex| { + # This is written in a kinda silly but simple way. + # It checks to ensure a few points in the sprite are all background colored. + # This must be run before drawing the player. + basePoints = [ + { x: 11, y: 2 }, + { x: 13, y: 3 }, + { x: 3, y: 5 }, + { x: 11, y: 6 }, + { x: 9, y: 8 }, + { x: 5, y: 9 }, + { x: 7, y: 10 }, + { x: 5, y: 12 }, + ] + + collisionPoints = + if animIndex == 2 { + List.append(List.append(basePoints, { x: 2, y: 1 }), { x: 7, y: 1 }) + } else if animIndex == 1 { + List.append(basePoints, { x: 2, y: 2 }) + } else { + basePoints + } + + var $collided = Bool.False + for { x, y } in collisionPoints { + if !$collided { + point = { + x: I32.to_u8_wrap(playerX + x), + y: I32.to_u8_wrap(playerY + y), + } + color = W4.get_pixel!(point) + + if color != Color1 { + $collided = Bool.True + } else { + {} + } + } else { + {} + } + } + + $collided +} + +offScreenCollided! : () => Bool +offScreenCollided! = || { + point = { + x: I32.to_u8_wrap(playerX + 13), + y: I32.to_u8_wrap(0), + } + color = W4.get_pixel!(point) + color != Color1 +} + +# ===== Pipes ============================================= + +Pipe : { x : I32, gapStart : I32 } + +gapHeight = 40 + +drawPipes! = |sprite, pipes| { + for pipe in pipes { + drawPipe!(sprite, pipe) + } +} + +drawPipe! : Sprite, Pipe => {} +drawPipe! = |sprite, { x, gapStart }| { + setSpriteColors!() + Sprite.blit!(sprite, { x, y: gapStart - W4.screen_height(), flags: [FlipY] }) + Sprite.blit!(sprite, { x, y: gapStart + gapHeight, flags: [] }) +} + +updatePipes : List(Pipe) -> List(Pipe) +updatePipes = |pipes| { + moved = List.map(pipes, |pipe| { ..pipe, x: pipe.x - 1 }) + List.drop_if(moved, |pipe| pipe.x < -20) +} + +maybeGeneratePipe! : U64, U64 => Try(Pipe, [NoPipe]) +maybeGeneratePipe! = |lastgenerated, framecount| { + if framecount - lastgenerated > 90 { + gapStart = W4.rand_between!({ start: 0, before: 16 }) + Ok({ x: W4.screen_width(), gapStart: gapStart * 5 + 10 }) + } else { + Err(NoPipe) + } +} + +# ===== Plants ============================================ + +Plant : { x : I32, type : U32 } + +plantTypes = 30 + +plantY = W4.screen_height() - 22 + +randomPlant! : I32 => Plant +randomPlant! = |x| { + type = I32.to_u32_wrap(W4.rand!()) % plantTypes + { x, type } +} + +startingPlants! : () => List(Plant) +startingPlants! = || { + var $plants = List.with_capacity(20) + var $i = 0.I32 + + while $i < 14 { + plant = randomPlant!($i * 12) + $plants = List.append($plants, plant) + $i = $i + 1 + } + + $plants +} + +updatePlants : List(Plant) -> List(Plant) +updatePlants = |plants| { + moved = List.map(plants, |plant| { ..plant, x: plant.x - 1 }) + List.drop_if(moved, |plant| plant.x < -12) +} + +maybeGeneratePlant! : U64, U64 => Try(Plant, [NoPlant]) +maybeGeneratePlant! = |lastgenerated, framecount| { + if framecount - lastgenerated > 12 { + Ok(randomPlant!(W4.screen_width())) + } else { + Err(NoPlant) + } +} + +drawPlants! : Sprite, List(Plant) => {} +drawPlants! = |spriteSheet, plants| { + for plant in plants { + drawPlant!(spriteSheet, plant) + } +} + +drawPlant! : Sprite, Plant => {} +drawPlant! = |spriteSheet, { x, type }| { + sprite = Sprite.sub_or_crash(spriteSheet, { src_x: type * 12, src_y: 0, width: 12, height: 12 }) + setSpriteColors!() + Sprite.blit!(sprite, { x, y: plantY, flags: [] }) +} + +# ===== Sounds ============================================ + +flapTone = { + start_freq: 700, + end_freq: 870, + channel: Pulse1(Quarter), + pan: Center, + attack_time: 10, + sustain_time: 0, + decay_time: 5, + release_time: 3, + volume: 10, + peak_volume: 20, +} + +pointTone = { + start_freq: 995, + end_freq: 1000, + channel: Pulse2(Half), + pan: Center, + attack_time: 0, + sustain_time: 0, + decay_time: 10, + release_time: 10, + peak_volume: 75, + volume: 25, +} + +deathTone = { + start_freq: 170, + end_freq: 40, + channel: Noise, + pan: Center, + attack_time: 0, + sustain_time: 20, + decay_time: 40, + release_time: 0, + volume: 100, + peak_volume: 0, +} + +# ===== Drawing and Color ================================= + +drawScore! : U8, { x : I32, y : I32 } => {} +drawScore! = |score, { x: baseX, y }| { + setTextColors!() + x = + if score < 10 { + baseX + 8 + } else if score < 100 { + baseX + 4 + } else { + baseX + } + W4.text!(U8.to_str(score), { x, y }) +} + +drawGround! : Sprite, I32 => {} +drawGround! = |sprite, x| { + setGroundColors!() + Sprite.blit!(sprite, { x, y: W4.screen_height() - 13, flags: [] }) + Sprite.blit!(sprite, { x: x + W4.screen_width(), y: W4.screen_height() - 13, flags: [] }) +} + +setTextColors! : () => {} +setTextColors! = || + W4.set_text_colors!({ fg: Color4, bg: None }) + +setSpriteColors! : () => {} +setSpriteColors! = || + W4.set_draw_colors!({ primary: None, secondary: Color2, tertiary: Color3, quaternary: Color4 }) + +setGroundColors! : () => {} +setGroundColors! = || + W4.set_draw_colors!({ primary: Color1, secondary: Color2, tertiary: Color3, quaternary: Color4 }) + +# ===== Saving and Loading ================================ + +# Due to limitations in randomness of wasm4 we would always get the same title screen. +# This save just a single byte of randomness from the frameCount in order to give us a bit more randomness. +saveRandToDisk! : U64 => {} +saveRandToDisk! = |frameCount| { + data = U64.to_u8_wrap(U64.bitwise_and(frameCount, 0xFF)) + highScore = loadHighScoreFromDisk!() + _ = W4.save_to_disk!([data, highScore]) + {} +} + +loadRandFromDisk! : () => U64 +loadRandFromDisk! = || { + data = W4.load_from_disk!() + match data { + [byte, ..] => U8.to_u64(byte) + _ => 0 + } +} + +saveHighScoreToDisk! : U8 => {} +saveHighScoreToDisk! = |highScore| { + rand = loadRandFromDisk!() + _ = W4.save_to_disk!([U64.to_u8_wrap(rand), highScore]) + {} +} + +loadHighScoreFromDisk! : () => U8 +loadHighScoreFromDisk! = || { + data = W4.load_from_disk!() + match data { + [_, hs, ..] => hs + _ => 0 + } +} + +# ===== Animations ======================================== + +AnimationState : [Completed, RunOnce, Loop] +Animation : { + lastUpdated : U64, + index : U64, + cells : List({ frames : U64, sprite : Sprite }), + state : AnimationState, +} + +updateAnimation : U64, Animation -> Animation +updateAnimation = |frameCount, anim| { + framesPerUpdate = + match List.get(anim.cells, anim.index) { + Ok(cell) => cell.frames + Err(_) => { crash "animation cell out of bounds at index: ${U64.to_str(anim.index)}" } + } + + if frameCount - anim.lastUpdated < framesPerUpdate { + anim + } else { + nextIndex = wrappedInc(anim.index, List.len(anim.cells)) + match anim.state { + Completed => + { ..anim, lastUpdated: frameCount } + + Loop => + { ..anim, index: nextIndex, lastUpdated: frameCount } + + RunOnce => + if nextIndex == 0 { + { ..anim, state: Completed, lastUpdated: frameCount } + } else { + { ..anim, index: nextIndex, lastUpdated: frameCount } + } + } + } +} + +drawAnimation! : Animation, { x : I32, y : I32, flags : List([FlipX, FlipY, Rotate]) } => {} +drawAnimation! = |anim, { x, y, flags }| + match List.get(anim.cells, anim.index) { + Ok(cell) => { + setSpriteColors!() + Sprite.blit!(cell.sprite, { x, y, flags }) + } + + Err(_) => { crash "animation cell out of bounds at index: ${U64.to_str(anim.index)}" } + } + +wrappedInc : U64, U64 -> U64 +wrappedInc = |val, count| { + next = val + 1 + if next == count { + 0 + } else { + next + } +} + +idleShift : U64, Animation -> I32 +idleShift = |frameCount, anim| + if anim.index == 2 { + 0 + } else if anim.index == 1 and frameCount - anim.lastUpdated > 3 { + 0 + } else { + 1 + } + +createRocciIdleAnim : U64 -> Animation +createRocciIdleAnim = |frameCount| { + lastUpdated: frameCount, + index: 0, + state: Loop, + cells: [ + { frames: 17, sprite: Sprite.sub_or_crash(rocciSpriteSheet, { src_x: 0, src_y: 0, width: 16, height: 16 }) }, + { frames: 6, sprite: Sprite.sub_or_crash(rocciSpriteSheet, { src_x: 16, src_y: 0, width: 16, height: 16 }) }, + { frames: 17, sprite: Sprite.sub_or_crash(rocciSpriteSheet, { src_x: 32, src_y: 0, width: 16, height: 16 }) }, + ], +} + +flapAllowed : U64, Animation -> Bool +flapAllowed = |frameCount, anim| + if anim.index == 2 { + Bool.True + } else if anim.index == 1 { + frameCount - anim.lastUpdated > 6 + } else { + Bool.False + } + +createRocciFlapAnim : U64 -> Animation +createRocciFlapAnim = |frameCount| { + lastUpdated: frameCount, + index: 2, + state: Completed, + cells: [ + { frames: 6, sprite: Sprite.sub_or_crash(rocciSpriteSheet, { src_x: 16, src_y: 0, width: 16, height: 16 }) }, + { frames: 12, sprite: Sprite.sub_or_crash(rocciSpriteSheet, { src_x: 32, src_y: 0, width: 16, height: 16 }) }, + { frames: 1, sprite: Sprite.sub_or_crash(rocciSpriteSheet, { src_x: 0, src_y: 0, width: 16, height: 16 }) }, + ], +} + +createRocciFallAnim : U64 -> Animation +createRocciFallAnim = |frameCount| { + lastUpdated: frameCount, + index: 0, + state: Loop, + cells: [ + { frames: 10, sprite: Sprite.sub_or_crash(rocciSpriteSheet, { src_x: 48, src_y: 0, width: 16, height: 16 }) }, + { frames: 10, sprite: Sprite.sub_or_crash(rocciSpriteSheet, { src_x: 64, src_y: 0, width: 16, height: 16 }) }, + ], +} + +createHighScoreAnim : U64 -> Animation +createHighScoreAnim = |frameCount| { + lastUpdated: frameCount, + index: 0, + state: Loop, + cells: [ + { frames: 5, sprite: Sprite.sub_or_crash(highScoreSpriteSheet, { src_x: 0, src_y: 0, width: 32, height: 16 }) }, + { frames: 5, sprite: Sprite.sub_or_crash(highScoreSpriteSheet, { src_x: 32, src_y: 0, width: 32, height: 16 }) }, + { frames: 5, sprite: Sprite.sub_or_crash(highScoreSpriteSheet, { src_x: 64, src_y: 0, width: 32, height: 16 }) }, + { frames: 5, sprite: Sprite.sub_or_crash(highScoreSpriteSheet, { src_x: 96, src_y: 0, width: 32, height: 16 }) }, + ], +} + +# ===== Sprites =========================================== + +# Due to a compiler bug, all of these will regenerate every frame. +# That is a lot of data initialization. That said, it seems to be fast enough in practice. + +rocciSpriteSheet : Sprite +rocciSpriteSheet = + Sprite.new({ + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x15, 0x56, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x81, 0x00, 0x00, 0x00, 0x80, 0x40, 0x05, 0x56, 0x81, 0x80, 0x14, 0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x80, 0x00, 0x02, 0x85, 0x00, 0x00, 0x02, 0x81, 0x40, 0x01, 0x56, 0xa5, 0xa0, 0x15, 0x00, 0x05, 0xa0, 0x00, 0x00, 0x05, 0xa0, 0x00, 0x0a, 0x95, 0x00, 0x00, 0x0a, 0x85, 0x40, 0x00, 0x56, 0xa9, 0x00, 0x15, 0x56, 0xa5, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x06, 0x55, 0x00, 0x00, 0x06, 0x55, 0x40, 0x00, 0x06, 0xaa, 0x00, 0x15, 0x5a, 0xaa, 0x00, 0x15, 0x56, 0xaa, 0x00, 0x00, 0x16, 0x95, 0x00, 0x00, 0x06, 0x95, 0x00, 0x00, 0x09, 0x55, 0x00, 0x15, 0x6a, 0xa9, 0x00, 0x05, 0x56, 0xa9, 0x00, 0x00, 0x16, 0x94, 0x00, 0x00, 0x16, 0x95, 0x00, 0x00, 0x09, 0x54, 0x00, 0x05, 0x6a, 0x94, 0x00, 0x01, 0x56, 0xa4, 0x00, 0x00, 0x1a, 0x94, 0x00, 0x00, 0x16, 0x94, 0x00, 0x00, 0x09, 0x50, 0x00, 0x00, 0x69, 0x50, 0x00, 0x00, 0x56, 0x90, 0x00, 0x00, 0x1a, 0xa0, 0x00, 0x00, 0x1a, 0xa0, 0x00, 0x00, 0x29, 0x40, 0x00, 0x00, 0x29, 0x40, 0x00, 0x00, 0x26, 0x40, 0x00, 0x00, 0x0a, 0xa0, 0x00, 0x00, 0x0a, 0xa0, 0x00, 0x00, 0x29, 0x00, 0x00, 0x00, 0x29, 0x00, 0x00, 0x00, 0x29, 0x00, 0x00, 0x00, 0x0a, 0x80, 0x00, 0x00, 0x0a, 0x80, 0x00, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x05, 0x40, 0x00, 0x00, 0x05, 0x40, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0xa5, 0x00, 0x00, 0x00, 0xa5, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + bpp: BPP2, + width: 80, + height: 16, + }) + +groundSprite : Sprite +groundSprite = + Sprite.new({ + data: [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x65, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x59, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x95, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x45, 0x14, 0x51, 0x85, 0x14, 0x51, 0x49, 0x14, 0x91, 0x45, 0x18, 0x51, 0x54, 0x51, 0x49, 0x24, 0x51, 0x46, 0x14, 0x51, 0x46, 0x15, 0x14, 0x61, 0x45, 0x14, 0x92, 0x45, 0x14, 0x61, 0x46, 0x14, 0x55, 0x14, 0x51, 0x45, 0x14, 0x61, 0x51, 0x45, 0x65, 0x66, 0x56, 0x56, 0x59, 0x56, 0x59, 0x56, 0x55, 0x65, 0x65, 0x56, 0x59, 0x56, 0x59, 0x95, 0x59, 0x59, 0x55, 0x99, 0x59, 0x59, 0x55, 0x95, 0x96, 0x55, 0x99, 0x55, 0x95, 0x95, 0x59, 0x55, 0x96, 0x55, 0x59, 0x59, 0x95, 0x95, 0x96, 0x55, 0x95, 0x95, 0x99, 0x66, 0x65, 0x99, 0x65, 0x96, 0x95, 0x96, 0x59, 0x59, 0x65, 0x99, 0x65, 0xa5, 0x65, 0x96, 0x56, 0x56, 0x65, 0x99, 0x96, 0x59, 0x99, 0x66, 0x5a, 0x56, 0x59, 0x65, 0x96, 0x56, 0x59, 0x66, 0x65, 0x65, 0x66, 0x59, 0x99, 0x59, 0xa6, 0x9a, 0x5a, 0x69, 0x6a, 0x5a, 0x66, 0xa5, 0xa6, 0x9a, 0x9a, 0x6a, 0x6a, 0x5a, 0x65, 0x69, 0xa6, 0xa6, 0x9a, 0x69, 0x69, 0xa5, 0xa6, 0x9a, 0x5a, 0x96, 0x56, 0x9a, 0x6a, 0x6a, 0xa6, 0x9a, 0x9a, 0x96, 0xa9, 0xa6, 0x96, 0x9a, 0x5a, 0x69, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa], + bpp: BPP2, + width: 160, + height: 13, + }) + +pipeSprite : Sprite +pipeSprite = + Sprite.new({ + data: [0x0a, 0xaa, 0xaa, 0xab, 0xf0, 0x25, 0x55, 0x55, 0x55, 0x5c, 0x26, 0x96, 0x6a, 0x9a, 0xac, 0x36, 0x96, 0x6a, 0x66, 0xac, 0x36, 0x96, 0x6a, 0x9a, 0xac, 0x0f, 0xff, 0xff, 0xff, 0xf0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0, 0x03, 0x65, 0x9a, 0x66, 0xc0, 0x03, 0x65, 0x9a, 0x9a, 0xc0], + bpp: BPP2, + width: 20, + height: 160, + }) + +plantSpriteSheet : Sprite +plantSpriteSheet = + Sprite.new({ + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x41, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x01, 0x40, 0x02, 0xaa, 0x00, 0x02, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xa0, 0x00, 0x00, 0x00, 0x0a, 0x00, 0xa0, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x40, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x01, 0x50, 0x00, 0x00, 0x00, 0x10, 0x05, 0x00, 0x08, 0x00, 0x80, 0x08, 0x08, 0x80, 0x00, 0x2a, 0x00, 0x00, 0x08, 0x08, 0x00, 0x00, 0x00, 0x20, 0x00, 0x08, 0x00, 0x20, 0x80, 0x00, 0x00, 0x00, 0x00, 0x20, 0x80, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x82, 0x00, 0x00, 0x00, 0x00, 0x00, 0x41, 0x40, 0x11, 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x15, 0x05, 0x14, 0x00, 0x28, 0x00, 0x51, 0x45, 0x00, 0x20, 0x01, 0x60, 0x20, 0x81, 0x60, 0x00, 0x80, 0x80, 0x0a, 0xa0, 0x16, 0x00, 0xa8, 0x00, 0x82, 0x15, 0x96, 0x02, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x82, 0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x20, 0x02, 0xa0, 0x00, 0x00, 0x28, 0x00, 0x02, 0x20, 0x00, 0x02, 0x82, 0x00, 0x06, 0x11, 0x11, 0x02, 0xb2, 0xb0, 0x02, 0x80, 0x08, 0x00, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa8, 0x00, 0x02, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x51, 0x40, 0x15, 0x11, 0x15, 0x00, 0x00, 0x00, 0x24, 0x20, 0x80, 0x51, 0x41, 0x50, 0x00, 0x22, 0x00, 0x14, 0x41, 0x44, 0x20, 0x55, 0x60, 0x28, 0x59, 0xa0, 0x02, 0x01, 0x60, 0x20, 0x21, 0x56, 0x02, 0x02, 0x00, 0x83, 0x55, 0xd6, 0x02, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8a, 0x00, 0x00, 0x10, 0x40, 0x02, 0xa8, 0x80, 0x09, 0x5c, 0x00, 0x08, 0x20, 0x08, 0x02, 0x00, 0x00, 0x0a, 0x0a, 0x28, 0x91, 0x44, 0x66, 0x09, 0x6d, 0x5c, 0x0a, 0x08, 0x20, 0x02, 0x0b, 0x00, 0x00, 0x20, 0x00, 0x02, 0x06, 0x00, 0x0a, 0x15, 0xa0, 0x00, 0x00, 0x00, 0x02, 0x15, 0xa0, 0x15, 0x15, 0x08, 0x00, 0x00, 0x00, 0x04, 0x86, 0x60, 0x15, 0x80, 0x48, 0x20, 0x02, 0x08, 0x50, 0x52, 0x94, 0x21, 0x55, 0x60, 0x21, 0x95, 0x60, 0x02, 0x15, 0x60, 0x80, 0x5a, 0xa8, 0x08, 0x05, 0x80, 0x81, 0x69, 0x56, 0x0a, 0xea, 0x00, 0x00, 0x28, 0x00, 0x02, 0xab, 0x80, 0x04, 0x45, 0x10, 0x0a, 0xea, 0x80, 0x03, 0x9a, 0x00, 0x28, 0xa8, 0x20, 0x02, 0x00, 0x00, 0x28, 0x08, 0xa0, 0x45, 0x19, 0x18, 0x03, 0xef, 0xeb, 0x28, 0x28, 0xa8, 0x08, 0x20, 0xc0, 0x00, 0x88, 0x00, 0x02, 0x05, 0x80, 0x26, 0x16, 0x18, 0x02, 0x00, 0x00, 0x02, 0x8a, 0x80, 0x0a, 0x15, 0x28, 0x00, 0x00, 0x08, 0x05, 0x99, 0xb0, 0x26, 0x08, 0xa0, 0x88, 0x02, 0x22, 0xa1, 0x42, 0x05, 0x0a, 0xaa, 0x80, 0x0a, 0xaa, 0x80, 0x00, 0xaa, 0x80, 0x85, 0x5a, 0x70, 0x08, 0x55, 0x80, 0x95, 0x55, 0x56, 0x2b, 0xae, 0x80, 0x00, 0x88, 0x00, 0x0a, 0xee, 0xa0, 0x11, 0x11, 0x40, 0x0b, 0xab, 0xa0, 0x0b, 0xed, 0xe0, 0xba, 0xba, 0xa0, 0x02, 0xa8, 0x00, 0x2a, 0x2a, 0xb8, 0x1a, 0x6a, 0x78, 0x29, 0xbe, 0x68, 0xae, 0xaa, 0xb8, 0x31, 0x81, 0xc0, 0x00, 0x80, 0x00, 0x02, 0x55, 0x80, 0x87, 0x18, 0x18, 0x09, 0x82, 0x80, 0x00, 0xaa, 0x00, 0x28, 0x08, 0x0a, 0x08, 0x20, 0x20, 0x05, 0x65, 0xd0, 0x08, 0x02, 0x00, 0x08, 0xaa, 0x20, 0x28, 0x8a, 0x14, 0x02, 0x17, 0x00, 0x02, 0x17, 0x00, 0x00, 0x27, 0x00, 0x2a, 0xa2, 0x70, 0x02, 0xaa, 0x00, 0x2a, 0xaa, 0xa8, 0x2b, 0xba, 0xa0, 0x00, 0x87, 0x00, 0x2b, 0xae, 0xa8, 0x06, 0x66, 0x80, 0x0b, 0xae, 0xb8, 0x25, 0xb9, 0x5c, 0xba, 0xea, 0xe8, 0x02, 0xe0, 0x00, 0xae, 0xae, 0xea, 0x0b, 0xaa, 0xe0, 0x96, 0x97, 0x96, 0xba, 0xba, 0xea, 0x31, 0x85, 0x70, 0x00, 0xa0, 0x00, 0x02, 0x67, 0x00, 0x87, 0x1c, 0x57, 0x21, 0xc8, 0x70, 0x00, 0x2c, 0x00, 0x08, 0x0a, 0x08, 0x28, 0xa8, 0xa8, 0x01, 0x5f, 0x54, 0x20, 0x00, 0x80, 0x28, 0x2e, 0x28, 0x22, 0x82, 0x0a, 0x02, 0x17, 0x00, 0x02, 0x17, 0x00, 0x00, 0x27, 0x00, 0x09, 0xc2, 0x70, 0x00, 0x9c, 0x00, 0x08, 0x55, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0xce, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xab, 0x5b, 0xea, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0x03, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0xac, 0x3c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + bpp: BPP2, + width: 360, + height: 12, + }) + +highScoreSpriteSheet : Sprite +highScoreSpriteSheet = Sprite.new({ + data: [0x28, 0x00, 0xa0, 0x02, 0x80, 0x0a, 0x00, 0x28, 0x02, 0x80, 0x0a, 0x00, 0x28, 0x00, 0xa0, 0x00, 0x00, 0x28, 0x00, 0xa0, 0x02, 0x80, 0x0a, 0x00, 0x00, 0x28, 0x00, 0xa0, 0x02, 0x80, 0x0a, 0x00, 0x96, 0x02, 0x58, 0x09, 0x60, 0x25, 0x80, 0x96, 0x09, 0x60, 0x25, 0x80, 0x96, 0x02, 0x58, 0x00, 0x00, 0x96, 0x02, 0x58, 0x09, 0x60, 0x25, 0x80, 0x00, 0x96, 0x02, 0x58, 0x09, 0x60, 0x25, 0x80, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf6, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x2f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0, 0x2f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf6, 0x2f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x96, 0x02, 0x58, 0x09, 0x60, 0x25, 0x80, 0x96, 0x00, 0x25, 0x80, 0x96, 0x02, 0x58, 0x09, 0x60, 0x02, 0x58, 0x09, 0x60, 0x25, 0x80, 0x96, 0x00, 0x02, 0x58, 0x09, 0x60, 0x25, 0x80, 0x96, 0x00, 0x28, 0x00, 0xa0, 0x02, 0x80, 0x0a, 0x00, 0x28, 0x00, 0x0a, 0x00, 0x28, 0x00, 0xa0, 0x02, 0x80, 0x00, 0xa0, 0x02, 0x80, 0x0a, 0x00, 0x28, 0x00, 0x00, 0xa0, 0x02, 0x80, 0x0a, 0x00, 0x28, 0x00], + bpp: BPP2, + width: 128, + height: 16, +}) diff --git a/test/cli/rocci_bird_postcheck_panic/platform/Host.roc b/test/cli/rocci_bird_postcheck_panic/platform/Host.roc new file mode 100644 index 00000000000..37b343f5571 --- /dev/null +++ b/test/cli/rocci_bird_postcheck_panic/platform/Host.roc @@ -0,0 +1,37 @@ +## Internal module exposing raw WASM-4 hosted effects. +## +## End users should import `W4` for the high-level, ergonomic API. +## These functions correspond 1:1 with the WASM-4 runtime imports. +## +## Hosted functions are sorted alphabetically below because the Roc ABI +## dispatches hosted calls positionally by alphabetical index. +Host := [].{ + blit! : List(U8), I32, I32, U32, U32, U32 => {} + blit_sub! : List(U8), I32, I32, U32, U32, U32, U32, U32, U32 => {} + disk_read! : () => List(U8) + disk_write! : List(U8) => Bool + get_draw_colors! : () => U16 + get_gamepad! : U8 => U8 + get_mouse_buttons! : () => U8 + get_mouse_x! : () => I16 + get_mouse_y! : () => I16 + get_netplay! : () => U8 + get_palette_color! : U8 => U32 + get_pixel! : U8, U8 => U8 + hline! : I32, I32, U32 => {} + line! : I32, I32, I32, I32 => {} + oval! : I32, I32, U32, U32 => {} + rand! : () => I32 + rand_range_less_than! : I32, I32 => I32 + rect! : I32, I32, U32, U32 => {} + seed_rand! : U64 => {} + set_draw_colors! : U16 => {} + set_hide_gamepad_overlay! : Bool => {} + set_palette! : U32, U32, U32, U32 => {} + set_pixel! : U8, U8, U8 => {} + set_preserve_frame_buffer! : Bool => {} + text! : Str, I32, I32 => {} + tone! : U32, U32, U16, U8 => {} + trace! : Str => {} + vline! : I32, I32, U32 => {} +} diff --git a/test/cli/rocci_bird_postcheck_panic/platform/Sprite.roc b/test/cli/rocci_bird_postcheck_panic/platform/Sprite.roc new file mode 100644 index 00000000000..df236e1d8e0 --- /dev/null +++ b/test/cli/rocci_bird_postcheck_panic/platform/Sprite.roc @@ -0,0 +1,113 @@ +## Sprites for drawing to the WASM-4 framebuffer. +## +## A [Sprite] is simply a list of bytes encoded with either 1 bit per pixel (`BPP1`) +## or 2 bits per pixel (`BPP2`), along with information about how to read the +## pixel data. +## +## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/guides/sprites) +import Host + +## Represents a [sprite](https://en.wikipedia.org/wiki/Sprite_(computer_graphics)) +## for drawing to the screen. +Sprite := { + data : List(U8), + bpp : [BPP1, BPP2], + stride : U32, + region : { src_x : U32, src_y : U32, width : U32, height : U32 }, +}.{ + ## A subregion of a [Sprite]. + SubRegion : { src_x : U32, src_y : U32, width : U32, height : U32 } + + ## Create a [Sprite] to be drawn or [blit](https://en.wikipedia.org/wiki/Bit_blit) + ## to the screen. + ## + ## ``` + ## fruit_sprite = Sprite.new({ + ## data: [0x00, 0xa0, 0x02, 0x00, 0x0e, 0xf0, 0x36, 0x5c, 0xd6, 0x57, 0xd5, 0x57, 0x35, 0x5c, 0x0f, 0xf0], + ## bpp: BPP2, + ## width: 8, + ## height: 8, + ## }) + ## ``` + new : { data : List(U8), bpp : [BPP1, BPP2], width : U32, height : U32 } -> Sprite + new = |{ data, bpp, width, height }| { + data, + bpp, + stride: width, + region: { + src_x: 0, + src_y: 0, + width, + height, + }, + } + + ## Draw a [Sprite] to the framebuffer. + ## + ## ``` + ## Sprite.blit!(fruit_sprite, { x: 0, y: 0, flags: [FlipX, Rotate] }) + ## ``` + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/reference/functions#blit-spriteptr-x-y-width-height-flags) + blit! : Sprite, { x : I32, y : I32, flags : List([FlipX, FlipY, Rotate]) } => {} + blit! = |sprite, { x, y, flags }| { + { src_x, src_y, width, height } = sprite.region + + format = match sprite.bpp { + BPP1 => 0 + BPP2 => 1 + } + + # Each flag occupies a distinct bit, so we can sum non-duplicate contributions. + flip_x_bit = if flags.contains(FlipX) { 2 } else { 0 } + flip_y_bit = if flags.contains(FlipY) { 4 } else { 0 } + rotate_bit = if flags.contains(Rotate) { 8 } else { 0 } + combined = format + flip_x_bit + flip_y_bit + rotate_bit + + Host.blit_sub!(sprite.data, x, y, width, height, src_x, src_y, sprite.stride, combined) + } + + ## Creates a [Sprite] referencing a subregion of the current [Sprite]. + ## This will return an error if the subregion does not fit in the current [Sprite]. + ## + ## ``` + ## sub_sprite_result = Sprite.sub(sprite, { src_x: 20, src_y: 0, width: 20, height: 20 }) + ## ``` + ## + ## Note: If your program should never generate an invalid subregion, + ## [sub_or_crash] enables avoiding the result and simpler code. + sub : Sprite, SubRegion -> Try(Sprite, [OutOfBounds]) + sub = |sprite, sub_region| { + current_region = sprite.region + + out_of_bound_x = sub_region.src_x + sub_region.width > current_region.width + out_of_bound_y = sub_region.src_y + sub_region.height > current_region.height + + if out_of_bound_x or out_of_bound_y { + Err(OutOfBounds) + } else { + new_region = { + src_x: current_region.src_x + sub_region.src_x, + src_y: current_region.src_y + sub_region.src_y, + width: sub_region.width, + height: sub_region.height, + } + Ok({ ..sprite, region: new_region }) + } + } + + ## Equivalent to the [sub] function, but will crash on error. + ## This is really useful for static sprite sheet data that needs subSprites extracted. + ## + ## ``` + ## sub_sprite = Sprite.sub_or_crash(sprite, { src_x: 20, src_y: 0, width: 20, height: 20 }) + ## ``` + ## + ## Warning: Will crash if the subregion is not contained within the sprite. + sub_or_crash : Sprite, SubRegion -> Sprite + sub_or_crash = |sprite, sub_region| + match Sprite.sub(sprite, sub_region) { + Ok(s) => s + Err(OutOfBounds) => { crash "out of bounds subregion when generating subsprite" } + } +} diff --git a/test/cli/rocci_bird_postcheck_panic/platform/W4.roc b/test/cli/rocci_bird_postcheck_panic/platform/W4.roc new file mode 100644 index 00000000000..cd3077ca27d --- /dev/null +++ b/test/cli/rocci_bird_postcheck_panic/platform/W4.roc @@ -0,0 +1,634 @@ +## # roc-wasm4 +## +## Build [WASM-4](https://wasm4.org) games using Roc. +## +## This module provides the high-level, ergonomic API for the WASM-4 platform. +## Internally it wraps the low-level effects exposed by `Host`. +import Host + +## The `Palette` consists of four colors. There is also `None` which is used to +## represent a transparent or no change color. Each pixel on the screen will be +## drawn using one of these colors. +## +## You may find it helpful to create an alias for your game. +## +## ``` +## red = Color2 +## green = Color3 +## +## W4.set_text_colors!({ fg: red, bg: green }) +## ``` +Palette : [None, Color1, Color2, Color3, Color4] + +## Represents the four colors in the `Palette`. +DrawColors : { + primary : Palette, + secondary : Palette, + tertiary : Palette, + quaternary : Palette, +} + +## Represents the current state of a [Player] gamepad. +Gamepad : { + button1 : Bool, + button2 : Bool, + left : Bool, + right : Bool, + up : Bool, + down : Bool, +} + +## Represents the current state of the mouse. +Mouse : { + x : I16, + y : I16, + left : Bool, + right : Bool, + middle : Bool, +} + +## Represents the current state of [Netplay](https://wasm4.org/docs/guides/multiplayer#netplay). +## +## Netplay connects gamepad inputs over the Internet using WebRTC. +Netplay : [ + Enabled(Player), + Disabled, +] + +## Represents a player. +## +## [WASM-4 supports realtime multiplayer](https://wasm4.org/docs/guides/multiplayer) of +## up to 4 players, either locally or online. +Player : [Player1, Player2, Player3, Player4] + +## Represents a fragment shader for raw operations with the framebuffer. +## +## A shader takes the pixel `(x, y)` and the current `Palette` color at that +## position, and returns the new color to draw. +Shader : U8, U8, Palette -> Palette + +W4 :: [].{ + + ## Width of the WASM-4 screen in pixels. + screen_width : () -> I32 + screen_width = || 160 + + ## Height of the WASM-4 screen in pixels. + screen_height : () -> I32 + screen_height = || 160 + + ## Set the color `Palette` for your game. + ## + ## ``` + ## W4.set_palette!({ + ## color1: 0xffffff, + ## color2: 0xff0000, + ## color3: 0x00ff00, + ## color4: 0x0000ff, + ## }) + ## ``` + ## + ## Warning: this will overwrite the existing `Palette`, changing all colors on the screen. + set_palette! : { color1 : U32, color2 : U32, color3 : U32, color4 : U32 } => {} + set_palette! = |{ color1, color2, color3, color4 }| + Host.set_palette!(color1, color2, color3, color4) + + ## Get the color `Palette` for your game. + ## + ## ``` + ## { color1, color2, color3, color4 } = W4.get_palette!() + ## ``` + get_palette! : () => { color1 : U32, color2 : U32, color3 : U32, color4 : U32 } + get_palette! = || { + color1: Host.get_palette_color!(0), + color2: Host.get_palette_color!(1), + color3: Host.get_palette_color!(2), + color4: Host.get_palette_color!(3), + } + + ## Set the draw colors for the next draw command. + ## + ## ``` + ## blue = Color1 + ## white = Color4 + ## W4.set_draw_colors!({ + ## primary: blue, + ## secondary: white, + ## tertiary: None, + ## quaternary: None, + ## }) + ## ``` + ## + ## Warning: this will overwrite any existing draw colors that are set. + set_draw_colors! : DrawColors => {} + set_draw_colors! = |colors| + Host.set_draw_colors!(W4.to_color_flags(colors)) + + ## Get the currently set draw colors. + ## + ## ``` + ## { primary, secondary, tertiary, quaternary } = W4.get_draw_colors!() + ## ``` + get_draw_colors! : () => DrawColors + get_draw_colors! = || W4.from_color_flags(Host.get_draw_colors!()) + + ## Helper for setting the primary drawing color. + ## + ## ``` + ## blue = Color1 + ## W4.set_primary_color!(blue) + ## ``` + ## + ## Warning: this will overwrite any existing draw colors, and sets the + ## secondary, tertiary and quaternary values to `None`. + set_primary_color! : Palette => {} + set_primary_color! = |primary| + W4.set_draw_colors!({ + primary, + secondary: None, + tertiary: None, + quaternary: None, + }) + + ## Helper for setting the draw colors for text. + ## + ## ``` + ## blue = Color1 + ## white = Color4 + ## W4.set_text_colors!({ fg: blue, bg: white }) + ## ``` + ## + ## Warning: this will overwrite any existing draw colors, and sets the + ## tertiary and quaternary values to `None`. + set_text_colors! : { fg : Palette, bg : Palette } => {} + set_text_colors! = |{ fg, bg }| + W4.set_draw_colors!({ + primary: fg, + secondary: bg, + tertiary: None, + quaternary: None, + }) + + ## Helper for colors when drawing a shape. + ## + ## ``` + ## blue = Color1 + ## white = Color4 + ## W4.set_shape_colors!({ border: blue, fill: white }) + ## ``` + ## + ## Warning: this will overwrite any existing draw colors, and sets the + ## tertiary and quaternary values to `None`. + set_shape_colors! : { border : Palette, fill : Palette } => {} + set_shape_colors! = |{ border, fill }| + W4.set_draw_colors!({ + primary: fill, + secondary: border, + tertiary: None, + quaternary: None, + }) + + ## Draw text to the screen. + ## + ## ``` + ## W4.text!("Hello, World", { x: 0, y: 0 }) + ## ``` + ## + ## Text color is the Primary draw color. + ## Background color is the Secondary draw color. + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/guides/text) + text! : Str, { x : I32, y : I32 } => {} + text! = |str, { x, y }| Host.text!(str, x, y) + + ## Draw a rectangle to the screen. + ## + ## ``` + ## W4.rect!({ x: 0, y: 10, width: 40, height: 60 }) + ## ``` + ## + ## Fill color is the Primary draw color. + ## Border color is the Secondary draw color. + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/reference/functions#rect-x-y-width-height) + rect! : { x : I32, y : I32, width : U32, height : U32 } => {} + rect! = |{ x, y, width, height }| Host.rect!(x, y, width, height) + + ## Draw an oval to the screen. + ## + ## ``` + ## W4.oval!({ x: 10, y: 20, width: 20, height: 30 }) + ## ``` + ## + ## Fill color is the Primary draw color. + ## Border color is the Secondary draw color. + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/reference/functions#oval-x-y-width-height) + oval! : { x : I32, y : I32, width : U32, height : U32 } => {} + oval! = |{ x, y, width, height }| Host.oval!(x, y, width, height) + + ## Draw a line between two points to the screen. + ## + ## ``` + ## W4.line!({ x: 0, y: 0 }, { x: 10, y: 10 }) + ## ``` + ## + ## Line color is the Primary draw color. + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/reference/functions#line-x1-y1-x2-y2) + line! : { x : I32, y : I32 }, { x : I32, y : I32 } => {} + line! = |{ x: x1, y: y1 }, { x: x2, y: y2 }| Host.line!(x1, y1, x2, y2) + + ## Draw a horizontal line starting at (x, y) with len to the screen. + ## + ## ``` + ## W4.hline!({ x: 10, y: 20, len: 30 }) + ## ``` + ## + ## Line color is the Primary draw color. + hline! : { x : I32, y : I32, len : U32 } => {} + hline! = |{ x, y, len }| Host.hline!(x, y, len) + + ## Draw a vertical line starting at (x, y) with len to the screen. + ## + ## ``` + ## W4.vline!({ x: 10, y: 20, len: 30 }) + ## ``` + ## + ## Line color is the Primary draw color. + vline! : { x : I32, y : I32, len : U32 } => {} + vline! = |{ x, y, len }| Host.vline!(x, y, len) + + ## Get the controls for a `Gamepad`. + ## + ## ``` + ## { button1, button2, left, right, up, down } = W4.get_gamepad!(Player1) + ## ``` + get_gamepad! : Player => Gamepad + get_gamepad! = |player| { + gamepad_number = match player { + Player1 => 1 + Player2 => 2 + Player3 => 3 + Player4 => 4 + } + + flags = Host.get_gamepad!(gamepad_number) + + { + # 1 BUTTON_1 + button1: W4.bit_is_set(flags, 0), + # 2 BUTTON_2 + button2: W4.bit_is_set(flags, 1), + # 16 BUTTON_LEFT + left: W4.bit_is_set(flags, 4), + # 32 BUTTON_RIGHT + right: W4.bit_is_set(flags, 5), + # 64 BUTTON_UP + up: W4.bit_is_set(flags, 6), + # 128 BUTTON_DOWN + down: W4.bit_is_set(flags, 7), + } + } + + ## Get the current `Mouse` position and button state. + ## + ## ``` + ## { x, y, left, right, middle } = W4.get_mouse!() + ## ``` + get_mouse! : () => Mouse + get_mouse! = || { + x = Host.get_mouse_x!() + y = Host.get_mouse_y!() + buttons = Host.get_mouse_buttons!() + + { + x, + y, + # 1 MOUSE_LEFT + left: W4.bit_is_set(buttons, 0), + # 2 MOUSE_RIGHT + right: W4.bit_is_set(buttons, 1), + # 4 MOUSE_MIDDLE + middle: W4.bit_is_set(buttons, 2), + } + } + + ## Get the `Netplay` status. + ## + ## ``` + ## netplay = W4.get_netplay!() + ## match netplay { + ## Enabled(Player1) => # .. + ## Enabled(Player2) => # .. + ## Enabled(Player3) => # .. + ## Enabled(Player4) => # .. + ## Disabled => # .. + ## } + ## ``` + ## + ## Note: All WASM-4 games that support local multiplayer automatically support netplay. + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/guides/multiplayer) + get_netplay! : () => Netplay + get_netplay! = || { + flags = Host.get_netplay!() + enabled = W4.bit_is_set(flags, 2) + if enabled { + # low 2 bits select the player + player = match flags % 4 { + 0 => Player1 + 1 => Player2 + 2 => Player3 + 3 => Player4 + _ => { crash "got invalid netplay player from the host" } + } + Enabled(player) + } else { + Disabled + } + } + + ## Seeds the global pseudo-random number generator. + ## + ## ``` + ## W4.seed_rand!(frames_since_start) + ## ``` + ## + ## Wasm4 exposes no way to seed a random number generator. + ## To work around this, it is suggested to count the number of frames the user + ## is on the title screen before starting the game and use that to seed the prng. + seed_rand! : U64 => {} + seed_rand! = |s| Host.seed_rand!(s) + + ## Generate a pseudo-random number between `Num.min_i32` and `Num.max_i32` (inclusive). + ## + ## ``` + ## i = W4.rand!() + ## ``` + rand! : () => I32 + rand! = || Host.rand!() + + ## Generate a pseudo-random number in the range `[start, before)`. + ## + ## ``` + ## # random number in the range 0-99 + ## i = W4.rand_between!({ start: 0, before: 100 }) + ## ``` + rand_between! : { start : I32, before : I32 } => I32 + rand_between! = |{ start, before }| Host.rand_range_less_than!(start, before) + + ## Prints a message to the debug console. + ## + ## ``` + ## W4.trace!("Hello, World") + ## ``` + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/guides/trace) + trace! : Str => {} + trace! = |str| Host.trace!(str) + + ## Saves data to persistent storage. Any previously saved data on the disk is replaced. + ## + ## Returns `Err(SaveFailed)` on failure. + ## + ## ``` + ## result = W4.save_to_disk!([0x10]) + ## match result { + ## Ok({}) => # success + ## Err(SaveFailed) => # handle failure + ## } + ## ``` + ## + ## Games can persist up to 1024 bytes of data. + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/guides/diskw) + save_to_disk! : List(U8) => Try({}, [SaveFailed]) + save_to_disk! = |data| + if Host.disk_write!(data) { + Ok({}) + } else { + Err(SaveFailed) + } + + ## Gets all saved data from persistent storage. + ## + ## ``` + ## data = W4.load_from_disk!() + ## ``` + ## + ## Games can persist up to 1024 bytes of data. + ## + ## [Refer to the WASM-4 docs for more information](https://wasm4.org/docs/guides/diskw) + load_from_disk! : () => List(U8) + load_from_disk! = || Host.disk_read!() + + ## Set a flag to keep the framebuffer between frames. + ## + ## This can be helpful if you only want to update part of the screen. + preserve_frame_buffer! : () => {} + preserve_frame_buffer! = || Host.set_preserve_frame_buffer!(Bool.True) + + ## Set a flag to clear the framebuffer between frames. + clear_frame_buffer_each_update! : () => {} + clear_frame_buffer_each_update! = || Host.set_preserve_frame_buffer!(Bool.False) + + ## Set a flag to hide the gamepad overlay. + hide_gamepad_overlay! : () => {} + hide_gamepad_overlay! = || Host.set_hide_gamepad_overlay!(Bool.True) + + ## Set a flag to show the gamepad overlay. + show_gamepad_overlay! : () => {} + show_gamepad_overlay! = || Host.set_hide_gamepad_overlay!(Bool.False) + + ## Get the color for an individual pixel in the framebuffer. + get_pixel! : { x : U8, y : U8 } => Palette + get_pixel! = |{ x, y }| W4.extract_color(Host.get_pixel!(x, y)) + + ## Set the color for an individual pixel in the framebuffer. + set_pixel! : { x : U8, y : U8 }, Palette => {} + set_pixel! = |{ x, y }, color| { + bits = match color { + None => 0x0 + Color1 => 0x1 + Color2 => 0x2 + Color3 => 0x3 + Color4 => 0x4 + } + Host.set_pixel!(x, y, bits) + } + + ## Run a fragment `Shader` on the raw framebuffer. + ## + ## The shader is invoked for every pixel `(x, y)` on the screen, and its + ## return value is written back to the framebuffer. + run_shader! : Shader => {} + run_shader! = |shader| { + var $y = 0.U8 + while $y < 160 { + var $x = 0.U8 + while $x < 160 { + color = W4.get_pixel!({ x: $x, y: $y }) + new_color = shader($x, $y, color) + W4.set_pixel!({ x: $x, y: $y }, new_color) + $x = $x + 1 + } + $y = $y + 1 + } + } + + ## Plays a tone sound. + ## + ## Please refer to the [WASM-4 audio docs](https://wasm4.org/docs/guides/audio/). + ## + ## The sound.roc example app along with the + ## [WASM-4 sound tools](https://wasm4.org/docs/guides/audio/#sound-tool) can be + ## quite helpful to play with. + tone! : + { + start_freq : U16, + end_freq : U16, + channel : [ + Pulse1([Eighth, Quarter, Half, ThreeQuarters]), + Pulse2([Eighth, Quarter, Half, ThreeQuarters]), + Triangle, + Noise, + ], + pan : [Center, Left, Right], + sustain_time : U8, + release_time : U8, + decay_time : U8, + attack_time : U8, + volume : U8, + peak_volume : U8, + } + => {} + tone! = |{ start_freq, end_freq, channel, pan, sustain_time, release_time, decay_time, attack_time, volume, peak_volume }| { + # Each component occupies a disjoint byte/range, so OR is equivalent to addition. + freq = U32.shift_left_by(U16.to_u32(end_freq), 16) + U16.to_u32(start_freq) + + duration = + U32.shift_left_by(U8.to_u32(attack_time), 24) + + U32.shift_left_by(U8.to_u32(decay_time), 16) + + U32.shift_left_by(U8.to_u32(release_time), 8) + + U8.to_u32(sustain_time) + + volume_bits = U16.shift_left_by(U8.to_u16(peak_volume), 8) + U8.to_u16(volume) + + pan_bits : U8 + pan_bits = match pan { + Center => 0 + # pub const TONE_PAN_LEFT: u32 = 16; + Left => 16 + # pub const TONE_PAN_RIGHT: u32 = 32; + Right => 32 + } + + (channel_bits, mode_bits) = match channel { + # pub const TONE_PULSE1: u32 = 0; + Pulse1(mode) => (0.U8, W4.convert_mode(mode)) + # pub const TONE_PULSE2: u32 = 1; + Pulse2(mode) => (1.U8, W4.convert_mode(mode)) + # pub const TONE_TRIANGLE: u32 = 2; + Triangle => (2.U8, 0.U8) + # pub const TONE_NOISE: u32 = 3; + Noise => (3.U8, 0.U8) + } + + # channel_bits is in bits 0-1, mode_bits is in bits 2-3, pan_bits is in bits 4-5. + flags = pan_bits + mode_bits + channel_bits + + Host.tone!(freq, duration, volume_bits, flags) + } + + # HELPERS ------ + + ## Returns Bool.True if bit `n` (0-indexed from LSB) is set in `byte`. + bit_is_set : U8, U8 -> Bool + bit_is_set = |byte, n| U8.shift_right_zf_by(byte, n) % 2 == 1 + + convert_mode : [Eighth, Quarter, Half, ThreeQuarters] -> U8 + convert_mode = |m| match m { + # pub const TONE_MODE1: u32 = 0; + Eighth => 0 + # pub const TONE_MODE2: u32 = 4; + Quarter => 4 + # pub const TONE_MODE3: u32 = 8; + Half => 8 + # pub const TONE_MODE4: u32 = 12; + ThreeQuarters => 12 + } + + to_color_flags : DrawColors -> U16 + to_color_flags = |{ primary, secondary, tertiary, quaternary }| { + pos1 = match primary { + None => 0x0 + Color1 => 0x1 + Color2 => 0x2 + Color3 => 0x3 + Color4 => 0x4 + } + + pos2 = match secondary { + None => 0x00 + Color1 => 0x10 + Color2 => 0x20 + Color3 => 0x30 + Color4 => 0x40 + } + + pos3 = match tertiary { + None => 0x000 + Color1 => 0x100 + Color2 => 0x200 + Color3 => 0x300 + Color4 => 0x400 + } + + pos4 = match quaternary { + None => 0x0000 + Color1 => 0x1000 + Color2 => 0x2000 + Color3 => 0x3000 + Color4 => 0x4000 + } + + # The four positions occupy disjoint nibbles, so OR is equivalent to addition. + pos1 + pos2 + pos3 + pos4 + } + + extract_color : U8 -> Palette + extract_color = |pos| match pos { + 0x0 => None + 0x1 => Color1 + 0x2 => Color2 + 0x3 => Color3 + 0x4 => Color4 + _ => { crash "got invalid draw color from the host" } + } + + from_color_flags : U16 -> DrawColors + from_color_flags = |flags| { + # Each draw color occupies one nibble (4 bits); extract via shift + mod. + pos1 = U16.to_u8_wrap(flags % 16) + pos2 = U16.to_u8_wrap(U16.shift_right_zf_by(flags, 4) % 16) + pos3 = U16.to_u8_wrap(U16.shift_right_zf_by(flags, 8) % 16) + pos4 = U16.to_u8_wrap(U16.shift_right_zf_by(flags, 12) % 16) + + { + primary: W4.extract_color(pos1), + secondary: W4.extract_color(pos2), + tertiary: W4.extract_color(pos3), + quaternary: W4.extract_color(pos4), + } + } +} + +expect W4.to_color_flags({ primary: Color2, secondary: Color4, tertiary: None, quaternary: None }) == 0x0042 +expect W4.to_color_flags({ primary: Color1, secondary: Color2, tertiary: Color3, quaternary: Color4 }) == 0x4321 + +# NOTE: the following expects round-trip from_color_flags, but currently trip a +# Roc compiler bug: `expect` calling a function that returns a closed tag-union +# type alias produces an "infinite type" error. Once Roc fixes this, they can be re-enabled. +# expect W4.from_color_flags(0x0042) == { primary: Color2, secondary: Color4, tertiary: None, quaternary: None } +# expect W4.from_color_flags(0x4321) == { primary: Color1, secondary: Color2, tertiary: Color3, quaternary: Color4 } diff --git a/test/cli/rocci_bird_postcheck_panic/platform/main.roc b/test/cli/rocci_bird_postcheck_panic/platform/main.roc new file mode 100644 index 00000000000..70844b40f3f --- /dev/null +++ b/test/cli/rocci_bird_postcheck_panic/platform/main.roc @@ -0,0 +1,72 @@ +platform "" + requires { + [Model : model] for main : { init! : () => model, update! : model => model } + } + exposes [W4, Sprite, Host] + packages {} + provides { "init_for_host": init_for_host!, "update_for_host": update_for_host! } + hosted { + "host_blit": Host.blit!, + "host_blit_sub": Host.blit_sub!, + "host_disk_read": Host.disk_read!, + "host_disk_write": Host.disk_write!, + "host_get_draw_colors": Host.get_draw_colors!, + "host_get_gamepad": Host.get_gamepad!, + "host_get_mouse_buttons": Host.get_mouse_buttons!, + "host_get_mouse_x": Host.get_mouse_x!, + "host_get_mouse_y": Host.get_mouse_y!, + "host_get_netplay": Host.get_netplay!, + "host_get_palette_color": Host.get_palette_color!, + "host_get_pixel": Host.get_pixel!, + "host_hline": Host.hline!, + "host_line": Host.line!, + "host_oval": Host.oval!, + "host_rand": Host.rand!, + "host_rand_range_less_than": Host.rand_range_less_than!, + "host_rect": Host.rect!, + "host_seed_rand": Host.seed_rand!, + "host_set_draw_colors": Host.set_draw_colors!, + "host_set_hide_gamepad_overlay": Host.set_hide_gamepad_overlay!, + "host_set_palette": Host.set_palette!, + "host_set_pixel": Host.set_pixel!, + "host_set_preserve_frame_buffer": Host.set_preserve_frame_buffer!, + "host_text": Host.text!, + "host_tone": Host.tone!, + "host_trace": Host.trace!, + "host_vline": Host.vline!, + } + targets: { + inputs_dir: "targets/", + wasm32: { + inputs: ["host.wasm", app], + output: Shared, + exports: ["start", "update"], + import_memory: Zeroed, + minimum_memory: 65536, + maximum_memory: 65536, + initial_stack_size: 14752, + global_base: wasm4_program_memory_base, + }, + } + +# WASM-4 owns 0x0000..0x199f for registers and the framebuffer. +# Program data starts after that reserved range, rounded up to 32-byte alignment. +wasm4_reserved_memory_end = 0x19a0 + +wasm4_program_memory_base = 0x19c0 + +import W4 +import Sprite +import Host + +init_for_host! : () => Box(Model) +init_for_host! = || { + init_fn! = main.init! + Box.box(init_fn!()) +} + +update_for_host! : Box(Model) => Box(Model) +update_for_host! = |boxed| { + update_fn! = main.update! + Box.box(update_fn!(Box.unbox(boxed))) +} diff --git a/test/cli/rocci_bird_postcheck_panic/platform/targets/wasm32/host.wasm b/test/cli/rocci_bird_postcheck_panic/platform/targets/wasm32/host.wasm new file mode 100644 index 00000000000..73dc6e48e18 Binary files /dev/null and b/test/cli/rocci_bird_postcheck_panic/platform/targets/wasm32/host.wasm differ diff --git a/test/echo/all_syntax_test.roc b/test/echo/all_syntax_test.roc index 6949b73fdaa..7f59c816e96 100644 --- a/test/echo/all_syntax_test.roc +++ b/test/echo/all_syntax_test.roc @@ -121,8 +121,12 @@ question_with_err_lambda = |strings| { # Use crash for placeholders you want to fill in later. implement_me_later : Str -> Str -implement_me_later = |_str| { - crash "not implemented" +implement_me_later = |str| { + if str == "" { + str + } else { + crash "not implemented" + } } # for loops can be easier to think about than List.fold (previously `List.walk`) diff --git a/test/fuzzing/fuzz-build.zig b/test/fuzzing/fuzz-build.zig index c1410d9eea7..ab428372911 100644 --- a/test/fuzzing/fuzz-build.zig +++ b/test/fuzzing/fuzz-build.zig @@ -117,7 +117,6 @@ pub fn zig_fuzz_test_inner(buf: [*]u8, len: isize, debug: bool) void { .{ .requests = lir_roots }, .{ .target_usize = targetUsize(selected_target), - .inline_mode = .none, .list_in_place_map = false, }, ) catch |err| switch (err) { diff --git a/test/snapshots/comprehensive/Container.md b/test/snapshots/comprehensive/Container.md index 5ffa7e79ee5..c88aaa94f9b 100644 --- a/test/snapshots/comprehensive/Container.md +++ b/test/snapshots/comprehensive/Container.md @@ -389,7 +389,7 @@ EndOfFile, (ty-var (raw "c")) (ty-var (raw "d"))) (where - (method (module-of "a") (name "map") + (method (module-of "a") (name "map") (effectful false) (args (ty-var (raw "a")) (ty-fn @@ -988,7 +988,7 @@ main = { (ty-rigid-var (name "c")) (ty-rigid-var (name "d"))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "map") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "map") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (ty-parens diff --git a/test/snapshots/dev_object_multiline_string_long_issue_9464.md b/test/snapshots/dev_object_multiline_string_long_issue_9464.md index 6dcc2aee646..5775459e6c9 100644 --- a/test/snapshots/dev_object_multiline_string_long_issue_9464.md +++ b/test/snapshots/dev_object_multiline_string_long_issue_9464.md @@ -637,18 +637,18 @@ Line 299" ~~~ini x64mac=933d1e29d036080e34550297ca9cbb56d4e094ab7ba50aea14dea47d6982e3e4 x64win=ebb53edc10701e1f8a266a3aedc785db6a6bbe3ed36b31d325e13f364f7a25d6 -x64freebsd=16c606acb1363d1db9b65f8ed56446d5519cb51154219ad5e743ff56ebef9e9a -x64openbsd=16c606acb1363d1db9b65f8ed56446d5519cb51154219ad5e743ff56ebef9e9a -x64netbsd=16c606acb1363d1db9b65f8ed56446d5519cb51154219ad5e743ff56ebef9e9a -x64musl=16c606acb1363d1db9b65f8ed56446d5519cb51154219ad5e743ff56ebef9e9a -x64glibc=16c606acb1363d1db9b65f8ed56446d5519cb51154219ad5e743ff56ebef9e9a -x64linux=16c606acb1363d1db9b65f8ed56446d5519cb51154219ad5e743ff56ebef9e9a -x64elf=16c606acb1363d1db9b65f8ed56446d5519cb51154219ad5e743ff56ebef9e9a +x64freebsd=bb9ffb690cc6049feb3a5c3a8ce7af5fcffb1fed7f679040e55f54645f8ee8c8 +x64openbsd=bb9ffb690cc6049feb3a5c3a8ce7af5fcffb1fed7f679040e55f54645f8ee8c8 +x64netbsd=bb9ffb690cc6049feb3a5c3a8ce7af5fcffb1fed7f679040e55f54645f8ee8c8 +x64musl=bb9ffb690cc6049feb3a5c3a8ce7af5fcffb1fed7f679040e55f54645f8ee8c8 +x64glibc=bb9ffb690cc6049feb3a5c3a8ce7af5fcffb1fed7f679040e55f54645f8ee8c8 +x64linux=bb9ffb690cc6049feb3a5c3a8ce7af5fcffb1fed7f679040e55f54645f8ee8c8 +x64elf=bb9ffb690cc6049feb3a5c3a8ce7af5fcffb1fed7f679040e55f54645f8ee8c8 arm64mac=6aae978f440be319ed6cceb1b889ff7e153eebdfdd1454874740131e027788b9 arm64win=aeb2eba1df53d8aaca0d7ea4b3f2190e2812fffdc005b92951e0f573aa6731a7 -arm64linux=360c423d6cf1348830bbf658d9939c7eb7074a2cea840bb0105b8be97898c170 -arm64musl=360c423d6cf1348830bbf658d9939c7eb7074a2cea840bb0105b8be97898c170 -arm64glibc=360c423d6cf1348830bbf658d9939c7eb7074a2cea840bb0105b8be97898c170 +arm64linux=cf26d02e0ed8e1532b62272c60f1b9b4d66f3266e4822c9e720d19b87aa9f266 +arm64musl=cf26d02e0ed8e1532b62272c60f1b9b4d66f3266e4822c9e720d19b87aa9f266 +arm64glibc=cf26d02e0ed8e1532b62272c60f1b9b4d66f3266e4822c9e720d19b87aa9f266 arm32linux=NOT_IMPLEMENTED arm32musl=NOT_IMPLEMENTED wasm32=NOT_IMPLEMENTED diff --git a/test/snapshots/dev_object_static_data_exports.md b/test/snapshots/dev_object_static_data_exports.md index bf0b5f54758..f8a909b1601 100644 --- a/test/snapshots/dev_object_static_data_exports.md +++ b/test/snapshots/dev_object_static_data_exports.md @@ -123,18 +123,18 @@ tree = Node(box(BranchLeaf(5)), box(BranchPair(box(7), box(11)))) ~~~ini x64mac=6853a0ed28679952b0930d6276ee38532ffaf689eeb58deed6a674b81bba006a x64win=7520d6bce2d2480bd3a521960cc558a67c0812b676234b42bd25f30fd5f0336e -x64freebsd=2673ac590a12ed750de4e59743875ba845d44d664900e514a3e1293b42cc2754 -x64openbsd=2673ac590a12ed750de4e59743875ba845d44d664900e514a3e1293b42cc2754 -x64netbsd=2673ac590a12ed750de4e59743875ba845d44d664900e514a3e1293b42cc2754 -x64musl=2673ac590a12ed750de4e59743875ba845d44d664900e514a3e1293b42cc2754 -x64glibc=2673ac590a12ed750de4e59743875ba845d44d664900e514a3e1293b42cc2754 -x64linux=2673ac590a12ed750de4e59743875ba845d44d664900e514a3e1293b42cc2754 -x64elf=2673ac590a12ed750de4e59743875ba845d44d664900e514a3e1293b42cc2754 +x64freebsd=f9ebbbd1280cf5e354441dc63f269e7a42e9bd41f762b2ae1d44a0c1293b7d59 +x64openbsd=f9ebbbd1280cf5e354441dc63f269e7a42e9bd41f762b2ae1d44a0c1293b7d59 +x64netbsd=f9ebbbd1280cf5e354441dc63f269e7a42e9bd41f762b2ae1d44a0c1293b7d59 +x64musl=f9ebbbd1280cf5e354441dc63f269e7a42e9bd41f762b2ae1d44a0c1293b7d59 +x64glibc=f9ebbbd1280cf5e354441dc63f269e7a42e9bd41f762b2ae1d44a0c1293b7d59 +x64linux=f9ebbbd1280cf5e354441dc63f269e7a42e9bd41f762b2ae1d44a0c1293b7d59 +x64elf=f9ebbbd1280cf5e354441dc63f269e7a42e9bd41f762b2ae1d44a0c1293b7d59 arm64mac=47696a2e29c84dc4bbfe120765833dd28a3a1d324e5edd3646492db8f14b7561 arm64win=68fc0695aa9582dac5a39252d6aa7f18a5e508f7ba692874711f9807db53023e -arm64linux=a05d7c335bf54690139a496ec7aaf3c91d98e44ddf7d9828f915f7736d36afcd -arm64musl=a05d7c335bf54690139a496ec7aaf3c91d98e44ddf7d9828f915f7736d36afcd -arm64glibc=a05d7c335bf54690139a496ec7aaf3c91d98e44ddf7d9828f915f7736d36afcd +arm64linux=375aee8844a32ee71217d0d359f5a0eaf42b963696dda6f8c5363aa566b93f65 +arm64musl=375aee8844a32ee71217d0d359f5a0eaf42b963696dda6f8c5363aa566b93f65 +arm64glibc=375aee8844a32ee71217d0d359f5a0eaf42b963696dda6f8c5363aa566b93f65 arm32linux=NOT_IMPLEMENTED arm32musl=NOT_IMPLEMENTED wasm32=NOT_IMPLEMENTED diff --git a/test/snapshots/eval/list_fold.md b/test/snapshots/eval/list_fold.md index 1d44fd14c38..5ff685abf9d 100644 --- a/test/snapshots/eval/list_fold.md +++ b/test/snapshots/eval/list_fold.md @@ -143,7 +143,7 @@ expect sumResult == 10 (e-block (s-reassign (p-assign (ident "$state")) - (e-call (constraint-fn-var 204) + (e-call (constraint-fn-var 202) (e-lookup-local (p-assign (ident "step"))) (e-lookup-local @@ -166,7 +166,7 @@ expect sumResult == 10 (ty-rigid-var-lookup (ty-rigid-var (name "state")))))) (d-let (p-assign (ident "sumResult")) - (e-call (constraint-fn-var 386) + (e-call (constraint-fn-var 384) (e-lookup-local (p-assign (ident "fold"))) (e-list @@ -180,7 +180,7 @@ expect sumResult == 10 (args (p-assign (ident "acc")) (p-assign (ident "x"))) - (e-dispatch-call (method "plus") (constraint-fn-var 384) + (e-dispatch-call (method "plus") (constraint-fn-var 382) (receiver (e-lookup-local (p-assign (ident "acc")))) diff --git a/test/snapshots/fuzz_crash/fuzz_crash_023.md b/test/snapshots/fuzz_crash/fuzz_crash_023.md index cd9d5a5cb3b..202fe79ae49 100644 --- a/test/snapshots/fuzz_crash/fuzz_crash_023.md +++ b/test/snapshots/fuzz_crash/fuzz_crash_023.md @@ -2473,7 +2473,7 @@ expect { (e-literal (string ""))))))) (s-reassign (p-assign (ident "number")) - (e-dispatch-call (method "plus") (constraint-fn-var 4625) + (e-dispatch-call (method "plus") (constraint-fn-var 4623) (receiver (e-lookup-local (p-assign (ident "number")))) @@ -2543,7 +2543,7 @@ expect { (e-if (if-branches (if-branch - (e-dispatch-call (method "is_gt") (constraint-fn-var 5058) + (e-dispatch-call (method "is_gt") (constraint-fn-var 5056) (receiver (e-match (match @@ -2568,7 +2568,7 @@ expect { (value (e-num (value "12")))))))) (args - (e-dispatch-call (method "times") (constraint-fn-var 5053) + (e-dispatch-call (method "times") (constraint-fn-var 5051) (receiver (e-num (value "5"))) (args @@ -2583,18 +2583,18 @@ expect { (e-if (if-branches (if-branch - (e-dispatch-call (method "is_lt") (constraint-fn-var 5175) + (e-dispatch-call (method "is_lt") (constraint-fn-var 5173) (receiver - (e-dispatch-call (method "plus") (constraint-fn-var 5137) + (e-dispatch-call (method "plus") (constraint-fn-var 5135) (receiver (e-num (value "13"))) (args (e-num (value "2"))))) (args (e-num (value "5")))) - (e-dispatch-call (method "is_gte") (constraint-fn-var 5284) + (e-dispatch-call (method "is_gte") (constraint-fn-var 5282) (receiver - (e-dispatch-call (method "minus") (constraint-fn-var 5246) + (e-dispatch-call (method "minus") (constraint-fn-var 5244) (receiver (e-num (value "10"))) (args @@ -2609,11 +2609,11 @@ expect { (builtin) (e-tag (name "True"))))) (if-else - (e-dispatch-call (method "is_lte") (constraint-fn-var 5403) + (e-dispatch-call (method "is_lte") (constraint-fn-var 5401) (receiver (e-num (value "12"))) (args - (e-dispatch-call (method "div_by") (constraint-fn-var 5398) + (e-dispatch-call (method "div_by") (constraint-fn-var 5396) (receiver (e-num (value "3"))) (args @@ -2628,12 +2628,12 @@ expect { (e-match (match (cond - (e-dispatch-call (method "next_static_dispatch_method") (constraint-fn-var 5469) + (e-dispatch-call (method "next_static_dispatch_method") (constraint-fn-var 5467) (receiver (e-match (match (cond - (e-dispatch-call (method "static_dispatch_method") (constraint-fn-var 5436) + (e-dispatch-call (method "static_dispatch_method") (constraint-fn-var 5434) (receiver (e-match (match diff --git a/test/snapshots/fuzz_crash/fuzz_crash_080.md b/test/snapshots/fuzz_crash/fuzz_crash_080.md index 6a9b5f477eb..c0afa2451f7 100644 --- a/test/snapshots/fuzz_crash/fuzz_crash_080.md +++ b/test/snapshots/fuzz_crash/fuzz_crash_080.md @@ -58,7 +58,7 @@ EndOfFile, (s-type-anno (name "c") (ty (name "L")) (where - (method (module-of "o") (name "h") + (method (module-of "o") (name "h") (effectful false) (args) (ty-var (raw "a"))))))) ~~~ @@ -79,7 +79,7 @@ c : L (annotation (ty-malformed) (where - (method (ty-rigid-var (name "o")) (name "h") + (method (ty-rigid-var (name "o")) (name "h") (effectful false) (args) (ty-rigid-var (name "a"))))))) ~~~ diff --git a/test/snapshots/generalize_annotated_value_constrained.md b/test/snapshots/generalize_annotated_value_constrained.md index a7470b9b038..a50da9e958d 100644 --- a/test/snapshots/generalize_annotated_value_constrained.md +++ b/test/snapshots/generalize_annotated_value_constrained.md @@ -56,7 +56,7 @@ EndOfFile, (ty (name "List")) (ty-var (raw "a"))) (where - (method (module-of "a") (name "to_str") + (method (module-of "a") (name "to_str") (effectful false) (args (ty-var (raw "a"))) (ty (name "Str"))))) @@ -84,7 +84,7 @@ NO CHANGE (ty-apply (name "List") (builtin) (ty-rigid-var (name "a"))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_str") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_str") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "Str") (builtin)))))) diff --git a/test/snapshots/range_annotated.md b/test/snapshots/range_annotated.md index 000d2873310..c5c511edb6c 100644 --- a/test/snapshots/range_annotated.md +++ b/test/snapshots/range_annotated.md @@ -42,7 +42,7 @@ NO CHANGE (can-ir (d-let (p-assign (ident "r")) - (e-call (constraint-fn-var 267) + (e-call (constraint-fn-var 269) (e-lookup-external (builtin)) (e-num (value "0")) diff --git a/test/snapshots/range_exclusive.md b/test/snapshots/range_exclusive.md index a01888478e8..e6000cd4d0e 100644 --- a/test/snapshots/range_exclusive.md +++ b/test/snapshots/range_exclusive.md @@ -36,7 +36,7 @@ NO CHANGE (can-ir (d-let (p-assign (ident "r")) - (e-call (constraint-fn-var 205) + (e-call (constraint-fn-var 207) (e-lookup-external (builtin)) (e-num (value "1")) diff --git a/test/snapshots/range_for_loop.md b/test/snapshots/range_for_loop.md index 62114ebeab6..6dd4762510e 100644 --- a/test/snapshots/range_for_loop.md +++ b/test/snapshots/range_for_loop.md @@ -79,7 +79,7 @@ total = { (e-num (value "0"))) (s-for (p-assign (ident "i")) - (e-call (constraint-fn-var 265) + (e-call (constraint-fn-var 267) (e-lookup-external (builtin)) (e-num (value "1")) @@ -87,7 +87,7 @@ total = { (e-block (s-reassign (p-assign (ident "sum_")) - (e-dispatch-call (method "plus") (constraint-fn-var 388) + (e-dispatch-call (method "plus") (constraint-fn-var 390) (receiver (e-lookup-local (p-assign (ident "sum_")))) diff --git a/test/snapshots/range_inclusive.md b/test/snapshots/range_inclusive.md index 85ecd3cfa82..d22f7971b97 100644 --- a/test/snapshots/range_inclusive.md +++ b/test/snapshots/range_inclusive.md @@ -36,7 +36,7 @@ NO CHANGE (can-ir (d-let (p-assign (ident "r")) - (e-call (constraint-fn-var 211) + (e-call (constraint-fn-var 213) (e-lookup-external (builtin)) (e-num (value "1")) diff --git a/test/snapshots/range_missing_method_error.md b/test/snapshots/range_missing_method_error.md index 5d38edb0218..6231624006b 100644 --- a/test/snapshots/range_missing_method_error.md +++ b/test/snapshots/range_missing_method_error.md @@ -104,7 +104,7 @@ NO CHANGE (can-ir (d-let (p-assign (ident "r")) - (e-call (constraint-fn-var 173) + (e-call (constraint-fn-var 175) (e-lookup-external (builtin)) (e-string diff --git a/test/snapshots/range_precedence_and_fmt.md b/test/snapshots/range_precedence_and_fmt.md index 16ba2cfd67c..3994d23112f 100644 --- a/test/snapshots/range_precedence_and_fmt.md +++ b/test/snapshots/range_precedence_and_fmt.md @@ -48,11 +48,11 @@ r = 1.. b (ty-rigid-var (name "a")) (ty-rigid-var (name "b"))) (where - (method (ty-rigid-var (name "c")) (name "method") + (method (ty-rigid-var (name "c")) (name "method") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "c")))) (ty-rigid-var (name "d"))))))) diff --git a/test/snapshots/where_clause/where_clauses_minimal.md b/test/snapshots/where_clause/where_clauses_minimal.md index 17c1c9efe48..5853af1a4f5 100644 --- a/test/snapshots/where_clause/where_clauses_minimal.md +++ b/test/snapshots/where_clause/where_clauses_minimal.md @@ -30,7 +30,7 @@ EndOfFile, (ty-var (raw "a")) (ty-var (raw "b"))) (where - (method (module-of "a") (name "convert") + (method (module-of "a") (name "convert") (effectful false) (args (ty-var (raw "a"))) (ty-var (raw "b"))))) @@ -53,7 +53,7 @@ NO CHANGE (ty-rigid-var (name "a")) (ty-rigid-var (name "b"))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "convert") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "convert") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-rigid-var-lookup (ty-rigid-var (name "b")))))))) diff --git a/test/snapshots/where_clause/where_clauses_multi_type_vars.md b/test/snapshots/where_clause/where_clauses_multi_type_vars.md index 7f99e177127..2e7f07a6151 100644 --- a/test/snapshots/where_clause/where_clauses_multi_type_vars.md +++ b/test/snapshots/where_clause/where_clauses_multi_type_vars.md @@ -29,11 +29,11 @@ EndOfFile, (ty-var (raw "b")) (ty-var (raw "c"))) (where - (method (module-of "a") (name "convert") + (method (module-of "a") (name "convert") (effectful false) (args (ty-var (raw "a"))) (ty-var (raw "c"))) - (method (module-of "b") (name "transform") + (method (module-of "b") (name "transform") (effectful false) (args (ty-var (raw "b"))) (ty-var (raw "c"))))) @@ -65,11 +65,11 @@ NO CHANGE (ty-rigid-var (name "b")) (ty-rigid-var (name "c"))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "convert") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "convert") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-rigid-var-lookup (ty-rigid-var (name "c")))) - (method (ty-rigid-var-lookup (ty-rigid-var (name "b"))) (name "transform") + (method (ty-rigid-var-lookup (ty-rigid-var (name "b"))) (name "transform") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "b")))) (ty-rigid-var-lookup (ty-rigid-var (name "c")))))))) diff --git a/test/snapshots/where_clause/where_clauses_multiline.md b/test/snapshots/where_clause/where_clauses_multiline.md index 4f9625a63a6..8a92d0e845b 100644 --- a/test/snapshots/where_clause/where_clauses_multiline.md +++ b/test/snapshots/where_clause/where_clauses_multiline.md @@ -31,11 +31,11 @@ EndOfFile, (ty-var (raw "b")) (ty-var (raw "c"))) (where - (method (module-of "a") (name "convert") + (method (module-of "a") (name "convert") (effectful false) (args (ty-var (raw "a"))) (ty-var (raw "c"))) - (method (module-of "b") (name "transform") + (method (module-of "b") (name "transform") (effectful false) (args (ty-var (raw "b"))) (ty-var (raw "c"))))) @@ -59,11 +59,11 @@ NO CHANGE (ty-rigid-var (name "b")) (ty-rigid-var (name "c"))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "convert") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "convert") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-rigid-var-lookup (ty-rigid-var (name "c")))) - (method (ty-rigid-var-lookup (ty-rigid-var (name "b"))) (name "transform") + (method (ty-rigid-var-lookup (ty-rigid-var (name "b"))) (name "transform") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "b")))) (ty-rigid-var-lookup (ty-rigid-var (name "c")))))))) diff --git a/test/snapshots/where_clause/where_clauses_serde_example.md b/test/snapshots/where_clause/where_clauses_serde_example.md index 52d0bfe8485..d13cdf771dc 100644 --- a/test/snapshots/where_clause/where_clauses_serde_example.md +++ b/test/snapshots/where_clause/where_clauses_serde_example.md @@ -37,7 +37,7 @@ EndOfFile, (tags (ty (name "DecodeErr")))))) (where - (method (module-of "a") (name "decode") + (method (module-of "a") (name "decode") (effectful false) (args (ty-apply (ty (name "List")) @@ -77,7 +77,7 @@ NO CHANGE (ty-tag-union (ty-tag-name (name "DecodeErr"))))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "decode") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "decode") (effectful false) (args (ty-apply (name "List") (builtin) (ty-lookup (name "U8") (builtin)))) diff --git a/test/snapshots/where_clause/where_clauses_simple_dispatch.md b/test/snapshots/where_clause/where_clauses_simple_dispatch.md index a6ab81cb162..ff8bd912b59 100644 --- a/test/snapshots/where_clause/where_clauses_simple_dispatch.md +++ b/test/snapshots/where_clause/where_clauses_simple_dispatch.md @@ -28,7 +28,7 @@ EndOfFile, (ty-var (raw "a")) (ty (name "Str"))) (where - (method (module-of "a") (name "to_str") + (method (module-of "a") (name "to_str") (effectful false) (args (ty-var (raw "a"))) (ty (name "Str"))))) @@ -64,7 +64,7 @@ NO CHANGE (ty-rigid-var (name "a")) (ty-lookup (name "Str") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_str") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_str") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "Str") (builtin))))))) diff --git a/test/snapshots/where_clause/where_clauses_type_annotation.md b/test/snapshots/where_clause/where_clauses_type_annotation.md index b3959c9bb6c..c70262e621c 100644 --- a/test/snapshots/where_clause/where_clauses_type_annotation.md +++ b/test/snapshots/where_clause/where_clauses_type_annotation.md @@ -28,7 +28,7 @@ EndOfFile, (ty-var (raw "a")) (ty-var (raw "b"))) (where - (method (module-of "a") (name "to_b") + (method (module-of "a") (name "to_b") (effectful false) (args (ty-var (raw "a"))) (ty-var (raw "b"))))) @@ -64,7 +64,7 @@ NO CHANGE (ty-rigid-var (name "a")) (ty-rigid-var (name "b"))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_b") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_b") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-rigid-var-lookup (ty-rigid-var (name "b")))))))) diff --git a/test/snapshots/where_clause_forwarding_chain_evidence.md b/test/snapshots/where_clause_forwarding_chain_evidence.md index a3f5361b644..995f743b8dc 100644 --- a/test/snapshots/where_clause_forwarding_chain_evidence.md +++ b/test/snapshots/where_clause_forwarding_chain_evidence.md @@ -52,7 +52,7 @@ EndOfFile, (ty-var (raw "a")) (ty (name "Str"))) (where - (method (module-of "a") (name "describe") + (method (module-of "a") (name "describe") (effectful false) (args (ty-var (raw "a"))) (ty (name "Str"))))) @@ -70,7 +70,7 @@ EndOfFile, (ty-var (raw "a")) (ty (name "Str"))) (where - (method (module-of "a") (name "describe") + (method (module-of "a") (name "describe") (effectful false) (args (ty-var (raw "a"))) (ty (name "Str"))))) @@ -87,7 +87,7 @@ EndOfFile, (ty-var (raw "a")) (ty (name "Str"))) (where - (method (module-of "a") (name "describe") + (method (module-of "a") (name "describe") (effectful false) (args (ty-var (raw "a"))) (ty (name "Str"))))) @@ -167,7 +167,7 @@ main = f(Named.N("ok")) (ty-rigid-var (name "a")) (ty-lookup (name "Str") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "describe") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "describe") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "Str") (builtin)))))) @@ -186,7 +186,7 @@ main = f(Named.N("ok")) (ty-rigid-var (name "a")) (ty-lookup (name "Str") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "describe") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "describe") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "Str") (builtin)))))) @@ -205,7 +205,7 @@ main = f(Named.N("ok")) (ty-rigid-var (name "a")) (ty-lookup (name "Str") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "describe") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "describe") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "Str") (builtin)))))) diff --git a/test/snapshots/where_clause_nested_obligation_missing_method_issue_9892.md b/test/snapshots/where_clause_nested_obligation_missing_method_issue_9892.md index d90ea1a39e7..b6fd107969e 100644 --- a/test/snapshots/where_clause_nested_obligation_missing_method_issue_9892.md +++ b/test/snapshots/where_clause_nested_obligation_missing_method_issue_9892.md @@ -69,7 +69,7 @@ EndOfFile, (ty-var (raw "a"))) (ty (name "Str"))) (where - (method (module-of "a") (name "frobnicate") + (method (module-of "a") (name "frobnicate") (effectful false) (args (ty-var (raw "a"))) (ty (name "Str"))))) @@ -88,7 +88,7 @@ EndOfFile, (ty-var (raw "b")) (ty (name "Str"))) (where - (method (module-of "b") (name "unwrap") + (method (module-of "b") (name "unwrap") (effectful false) (args (ty-var (raw "b"))) (ty (name "Str"))))) @@ -144,7 +144,7 @@ main = run(Wrap.W(42.U8)) (ty-rigid-var (name "a"))) (ty-lookup (name "Str") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "frobnicate") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "frobnicate") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "Str") (builtin)))))) @@ -163,7 +163,7 @@ main = run(Wrap.W(42.U8)) (ty-rigid-var (name "b")) (ty-lookup (name "Str") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "b"))) (name "unwrap") + (method (ty-rigid-var-lookup (ty-rigid-var (name "b"))) (name "unwrap") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "b")))) (ty-lookup (name "Str") (builtin)))))) diff --git a/test/snapshots/where_to_i32_wrap_accepts_i32.md b/test/snapshots/where_to_i32_wrap_accepts_i32.md index 155b1d4f143..2440499c82a 100644 --- a/test/snapshots/where_to_i32_wrap_accepts_i32.md +++ b/test/snapshots/where_to_i32_wrap_accepts_i32.md @@ -40,7 +40,7 @@ EndOfFile, (ty-var (raw "a")) (ty (name "I32"))) (where - (method (module-of "a") (name "to_i32_wrap") + (method (module-of "a") (name "to_i32_wrap") (effectful false) (args (ty-var (raw "a"))) (ty (name "I32"))))) @@ -97,7 +97,7 @@ _ = function(value) (ty-rigid-var (name "a")) (ty-lookup (name "I32") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_i32_wrap") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_i32_wrap") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "I32") (builtin)))))) diff --git a/test/snapshots/where_to_str_accepts_str.md b/test/snapshots/where_to_str_accepts_str.md index 4555f317d44..c342bc55958 100644 --- a/test/snapshots/where_to_str_accepts_str.md +++ b/test/snapshots/where_to_str_accepts_str.md @@ -38,7 +38,7 @@ EndOfFile, (ty-var (raw "a")) (ty (name "Str"))) (where - (method (module-of "a") (name "to_str") + (method (module-of "a") (name "to_str") (effectful false) (args (ty-var (raw "a"))) (ty (name "Str"))))) @@ -93,7 +93,7 @@ _ = function(value) (ty-rigid-var (name "a")) (ty-lookup (name "Str") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_str") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_str") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "Str") (builtin)))))) diff --git a/test/snapshots/where_to_u32_try_accepts_u32.md b/test/snapshots/where_to_u32_try_accepts_u32.md index 5c41003ae61..9633f5db675 100644 --- a/test/snapshots/where_to_u32_try_accepts_u32.md +++ b/test/snapshots/where_to_u32_try_accepts_u32.md @@ -40,7 +40,7 @@ EndOfFile, (ty-var (raw "a")) (ty (name "U32"))) (where - (method (module-of "a") (name "to_u32_try") + (method (module-of "a") (name "to_u32_try") (effectful false) (args (ty-var (raw "a"))) (ty-apply @@ -108,7 +108,7 @@ _ = function(value) (ty-rigid-var (name "a")) (ty-lookup (name "U32") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_u32_try") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_u32_try") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-apply (name "Try") (builtin) diff --git a/test/snapshots/where_to_u64_accepts_u64.md b/test/snapshots/where_to_u64_accepts_u64.md index dfb4927001d..048bd0bd4a0 100644 --- a/test/snapshots/where_to_u64_accepts_u64.md +++ b/test/snapshots/where_to_u64_accepts_u64.md @@ -40,7 +40,7 @@ EndOfFile, (ty-var (raw "a")) (ty (name "U64"))) (where - (method (module-of "a") (name "to_u64") + (method (module-of "a") (name "to_u64") (effectful false) (args (ty-var (raw "a"))) (ty (name "U64"))))) @@ -97,7 +97,7 @@ _ = function(value) (ty-rigid-var (name "a")) (ty-lookup (name "U64") (builtin))) (where - (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_u64") + (method (ty-rigid-var-lookup (ty-rigid-var (name "a"))) (name "to_u64") (effectful false) (args (ty-rigid-var-lookup (ty-rigid-var (name "a")))) (ty-lookup (name "U64") (builtin)))))) diff --git a/test/wasm/boxed_model_update_static_lib_app.roc b/test/wasm/boxed_model_update_static_lib_app.roc new file mode 100644 index 00000000000..b95e538dd72 --- /dev/null +++ b/test/wasm/boxed_model_update_static_lib_app.roc @@ -0,0 +1,53 @@ +app [main!] { pf: platform "./static-lib-platform/main.roc" } + +TitleState : { + frame_count : U64, +} + +GameState : { + frame_count : U64, + last_generated : U64, +} + +Model : [ + Title(TitleState), + Game(GameState), +] + +update_frame_count = |prev| + { ..prev, frame_count: prev.frame_count + 1 } + +init_game = |state| + Game({ frame_count: state.frame_count, last_generated: state.frame_count }) + +update_model = |model| + match model { + Title(prev) => init_game({ ..prev, frame_count: prev.frame_count + 1 }) + Game(prev) => Game(update_frame_count(prev)) + } + +update_box : Box(Model) -> Box(Model) +update_box = |boxed| { + model = Box.unbox(boxed) + next = update_model(model) + Box.box(next) +} + +main! = |_seed| { + first = update_box(Box.box(Title({ frame_count: 5 }))) + first_model = Box.unbox(first) + model = update_model(first_model) + + match model { + Game(game) if game.frame_count == 7 and game.last_generated == 6 => "ok" + Game(game) if game.frame_count == 7 => "frame-ok" + Game(game) if game.frame_count == 1000000000000000006 and game.last_generated == 6 => "bad-val" + Game(game) if game.frame_count - game.last_generated == 1 => "diff-ok" + Game(game) if game.frame_count - game.last_generated == 1000000000000000000 => "diff-1e18" + Game(game) if game.frame_count - game.last_generated > 90 => "diff-high" + Game(game) if game.frame_count == 6 and game.last_generated == 6 => "stale" + Game(game) if game.last_generated == 6 => "last-ok" + Game(_) => "wrong-game" + _ => "not-game" + } +} diff --git a/test/wasm/iter_for_noiter_static_lib_app.roc b/test/wasm/iter_for_noiter_static_lib_app.roc new file mode 100644 index 00000000000..68b98986f2c --- /dev/null +++ b/test/wasm/iter_for_noiter_static_lib_app.roc @@ -0,0 +1,35 @@ +app [main!] { pf: platform "./static-lib-platform/main.roc" } + +# Noiter twin of iter_for_static_lib_app.roc: the same sums computed by +# `for` over plain list literals, with no minted iterator adapters. Its +# `--opt=size` wasm size is the baseline; the iter build's size minus this is +# the minted-adapter "premium" that CI tracks (and that the optional fusion +# pass exists to drive toward zero). +main! : U64 => Str +main! = |_seed| { + var append_sum = 0.U64 + for x in [1.U64, 2, 3, 9] { + append_sum = append_sum + x + } + + var map_sum = 0.U64 + for x in [2.U64, 3, 4] { + map_sum = map_sum + x + } + + var concat_sum = 0.U64 + for x in [1.U64, 2, 3, 4] { + concat_sum = concat_sum + x + } + + var chain_sum = 0.U64 + for x in [11.U64, 21, 100] { + chain_sum = chain_sum + x + } + + if append_sum == 15 and map_sum == 9 and concat_sum == 10 and chain_sum == 132 { + "ok" + } else { + "bad" + } +} diff --git a/test/wasm/iter_for_static_lib_app.roc b/test/wasm/iter_for_static_lib_app.roc new file mode 100644 index 00000000000..41b677a7fa3 --- /dev/null +++ b/test/wasm/iter_for_static_lib_app.roc @@ -0,0 +1,37 @@ +app [main!] { pf: platform "./static-lib-platform/main.roc" } + +# End-to-end cart gate for the minted-iterator `for`-loop drive on the +# `--opt=size` (LLVM) build path. A `for` over a minted chain sinks the loop +# into the chain and rebases the step's inline captures; a drive that loses the +# advanced successor `rest` freezes the inner iterator and never terminates +# (the regression that hung the Rocci cart at boot). Each `for` here is inlined +# in `main!` so it exercises that drive directly, and the whole app must boot +# and return "ok". +main! : U64 => Str +main! = |_seed| { + var append_sum = 0.U64 + for x in [1.U64, 2, 3].iter().append(9) { + append_sum = append_sum + x + } + + var map_sum = 0.U64 + for x in [1.U64, 2, 3].iter().map(|n| n + 1) { + map_sum = map_sum + x + } + + var concat_sum = 0.U64 + for x in [1.U64, 2].iter().concat([3.U64, 4].iter()) { + concat_sum = concat_sum + x + } + + var chain_sum = 0.U64 + for x in [10.U64, 20].iter().map(|n| n + 1).append(100) { + chain_sum = chain_sum + x + } + + if append_sum == 15 and map_sum == 9 and concat_sum == 10 and chain_sum == 132 { + "ok" + } else { + "bad" + } +} diff --git a/test/wasm/iter_list_hoist_static_lib_app.roc b/test/wasm/iter_list_hoist_static_lib_app.roc new file mode 100644 index 00000000000..47b3e807e52 --- /dev/null +++ b/test/wasm/iter_list_hoist_static_lib_app.roc @@ -0,0 +1,23 @@ +app [main!] { pf: platform "./static-lib-platform/main.roc" } + +# End-to-end cart gate for static-data hoisting of constant list literals +# consumed via `.iter()`. A constant list literal is materialized as static +# data (no runtime heap allocation), the minted adapters hold their predecessor +# by value, and the `for`-drive is scalarized — so the whole chain, base list +# included, allocates ZERO on the `--opt=size` cart path. The runner asserts +# `--max-allocs 0`, which the `--assert-alloc-balanced` iter_for gate cannot: +# a heap-built base list would allocate-and-free (balanced) yet still be caught +# here. +main! : U64 => Str +main! = |_seed| { + base_points = [ + { x: 11, y: 2 }, { x: 13, y: 3 }, { x: 3, y: 5 }, { x: 11, y: 6 }, + { x: 9, y: 8 }, { x: 5, y: 9 }, { x: 7, y: 10 }, { x: 5, y: 12 }, + ].iter() + collision_points = base_points.append({ x: 2, y: 1 }).append({ x: 7, y: 1 }) + var sum = 0.I64 + for { x, y } in collision_points { + sum = sum + x + y + } + if sum == 130 { "ok" } else { "bad" } +} diff --git a/test/wasm/iter_recursive_concat_static_lib_app.roc b/test/wasm/iter_recursive_concat_static_lib_app.roc new file mode 100644 index 00000000000..62ea1613e41 --- /dev/null +++ b/test/wasm/iter_recursive_concat_static_lib_app.roc @@ -0,0 +1,19 @@ +app [main!] { pf: platform "./static-lib-platform/main.roc" } + +grow : Iter(U64), U64 -> Iter(U64) +grow = |iter, remaining| + if remaining == 0 { + iter + } else { + grow(iter.concat(Iter.single(remaining)), remaining - 1) + } + +main! : U64 => Str +main! = |_seed| { + iter = grow([0.U64].iter(), 2) + var sum = 0.U64 + for item in iter { + sum = sum + item + } + if sum == 3 { "ok" } else { "bad" } +} diff --git a/test/wasm/main.zig b/test/wasm/main.zig index 0b3de938948..53860a02b78 100644 --- a/test/wasm/main.zig +++ b/test/wasm/main.zig @@ -43,6 +43,10 @@ const TestOptions = struct { assert_alloc_balanced: bool = false, min_allocs: usize = 0, max_allocs: ?usize = null, + /// When set, the `.wasm` file must be at most this many bytes. The size is + /// always printed, so pairing an iterator cart with its noiter twin makes + /// the minted-adapter premium (iter bytes minus noiter bytes) visible in CI. + max_bytes: ?usize = null, }; /// Host import implementations for the WASM module. @@ -230,6 +234,19 @@ fn callWasmMain(wasm: *const WasmInterface, allocator: std.mem.Allocator) anyerr /// Run a single test case. fn runTest(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, wasm_path: []const u8, expected_output: []const u8, options: TestOptions) TestResult { + if (std.Io.Dir.cwd().readFileAlloc(io, wasm_path, arena, .unlimited)) |bytes| { + std.debug.print("WASM size: {d} bytes\n", .{bytes.len}); + if (options.max_bytes) |max_bytes| { + if (bytes.len > max_bytes) { + return .{ + .name = wasm_path, + .passed = false, + .message = std.fmt.allocPrint(arena, "WASM size {d} exceeded --max-bytes {d}", .{ bytes.len, max_bytes }) catch "WASM too large", + }; + } + } + } else |_| {} + var wasm = setupWasm(gpa, arena, io, wasm_path, options) catch |err| { return .{ .name = wasm_path, @@ -371,6 +388,15 @@ pub fn main(init: std.process.Init) anyerror!void { std.debug.print("Error: invalid --max-allocs value '{s}': {}\n", .{ max_allocs_arg, err }); return; }; + } else if (std.mem.eql(u8, arg, "--max-bytes")) { + const max_bytes_arg = arg_iter.next() orelse { + std.debug.print("Error: --max-bytes requires an argument\n", .{}); + return; + }; + options.max_bytes = std.fmt.parseInt(usize, max_bytes_arg, 10) catch |err| { + std.debug.print("Error: invalid --max-bytes value '{s}': {}\n", .{ max_bytes_arg, err }); + return; + }; } } diff --git a/test/wasm/rc_cleanup_model_list_static_lib_app.roc b/test/wasm/rc_cleanup_model_list_static_lib_app.roc index 6e8f63486e0..d2078e749f4 100644 --- a/test/wasm/rc_cleanup_model_list_static_lib_app.roc +++ b/test/wasm/rc_cleanup_model_list_static_lib_app.roc @@ -13,6 +13,7 @@ Model : { step : Box(Model) -> Box(Model) step = |boxed| { model = Box.unbox(boxed) + expect model.tick == model.tick moved = List.map(model.plants, |plant| { ..plant, x: plant.x - 1 }) kept = List.drop_if(moved, |plant| plant.x < -12) diff --git a/test/wasm/rc_cleanup_static_lib_app.roc b/test/wasm/rc_cleanup_static_lib_app.roc index cf8d21b4d53..cb06b3952ef 100644 --- a/test/wasm/rc_cleanup_static_lib_app.roc +++ b/test/wasm/rc_cleanup_static_lib_app.roc @@ -3,6 +3,7 @@ app [main!] { pf: platform "./static-lib-platform/main.roc" } step : Box(U64) -> Box(U64) step = |boxed| { model = Box.unbox(boxed) + expect model == model temp = List.concat([1.U8, 2.U8, 3.U8], [4.U8, 5.U8, 6.U8]) Box.box(model + List.len(temp)) diff --git a/vendor/llvm_compile_bindings.zig b/vendor/llvm_compile_bindings.zig index 1bc8db48b31..3e03d31664e 100644 --- a/vendor/llvm_compile_bindings.zig +++ b/vendor/llvm_compile_bindings.zig @@ -210,6 +210,7 @@ pub const TargetMachine = opaque { bitcode_filename: ?[*:0]const u8, coverage: Coverage, no_target_libcalls: bool, + lower_memory_intrinsics_to_loops: bool, pub const LtoPhase = enum(c_int) { None, @@ -643,6 +644,7 @@ pub fn compileBitcodeToObject( .bitcode_filename = null, .coverage = default_coverage, .no_target_libcalls = false, + .lower_memory_intrinsics_to_loops = false, }; // Emit object file diff --git a/vendor/llvm_ir/Builder.zig b/vendor/llvm_ir/Builder.zig index c9679aab659..a2e5529ba34 100644 --- a/vendor/llvm_ir/Builder.zig +++ b/vendor/llvm_ir/Builder.zig @@ -8979,9 +8979,19 @@ pub fn deinit(self: *Builder) void { self.* = undefined; } -/// Finalizes the module-level inline assembly, ensuring it ends with a newline. +/// Adds module-level inline assembly, ensuring the accumulated text ends with a newline. pub fn finishModuleAsm(self: *Builder, aw: *Writer.Allocating) Allocator.Error!void { - self.module_asm = aw.toArrayList(); + if (self.module_asm.items.len == 0) { + self.module_asm = aw.toArrayList(); + } else { + if (self.module_asm.getLastOrNull()) |last| if (last != '\n') + try self.module_asm.append(self.gpa, '\n'); + + var next = aw.toArrayList(); + defer next.deinit(self.gpa); + try self.module_asm.appendSlice(self.gpa, next.items); + } + if (self.module_asm.getLastOrNull()) |last| if (last != '\n') try self.module_asm.append(self.gpa, '\n'); }