From 9ce51ba94eb345b0bd4d627ba80513f0a82bb271 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Mon, 21 Sep 2026 09:41:33 +0700 Subject: [PATCH 1/2] One slice over everything with elements, and the warning at the push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit as-slice was a warning, not an operation. The input type already decides which of the two things happens — a Vec can only be borrowed, an array or a string can only be viewed, and no call site picks between them — so the second name expressed no choice a reader could make. And it warned at the moment the view is taken, which is the one moment nothing is wrong; the danger arrives later, at the push. slice now takes a Vec at all three arities and as-slice is gone. (slice v lo) was free, and is the arity the Vec never had: the runtime already reads a hi of -1 as "to the end", so the tail form passes the caller's lo and the same -1 — no slot, no length read, no second evaluation. The merge is entirely in the checker; the Vec path builds the flan_vec_as_slice call it always built and neither backend has a line about any of it. A Vec a call returned is refused at every arity, and not for the array's reason. (slice (mk)) over an array dangles. (slice (make-vec)) does not — the storage outlives the expression — but the header is a temporary, so nothing can ever free the block. The refusal says that and names the let. The name's own refusal sits in ordinary_call after every table, so a program that defines an as-slice still reaches its own. It reads for somebody who has never heard of the old name and writes the call back out, spelling each argument that is a name or a number. The warning moved to where it bites: BUILT.md gains a section beside the Vec table and the push row points at it, spec-memory.md's Borrowing says the same. Investigated and deliberately not built — a diagnostic for a live view at the push. (reserve v 100) then a slice, a push and a read is correct code under the contract the spec chose, so any flag on it is a false positive by the language's own semantics rather than by an approximation. FIX.org has the finding and the syntactic sketch that does not work. --- FIX.org | 112 +++++++++++++++++++ NEXT.md | 8 +- docs/BUILT.md | 30 +++-- docs/SPIKE-DYNAMIC.md | 2 +- docs/overview.md | 2 +- lib/check.ml | 166 +++++++++++++++++++++------- lib/prelude.ml | 14 +-- lib/render.ml | 4 +- lib/shim.ml | 2 +- runtime/flan_rt.c | 4 +- spec-memory.md | 16 ++- syntax-sketch.flan | 2 +- test/programs/algorithms.flan | 10 +- test/programs/bounds-condition.flan | 6 +- test/programs/edn-read.flan | 4 +- test/programs/format.flan | 8 +- test/programs/generics.flan | 4 +- test/programs/global-init.flan | 2 +- test/programs/init-conditions.flan | 2 +- test/programs/into.flan | 12 +- test/programs/json.flan | 2 +- test/programs/shim-nul.flan | 2 +- test/programs/slurp.flan | 10 +- test/programs/string-eq.flan | 2 +- test/programs/strings.flan | 6 +- test/programs/vec-global.flan | 2 +- test/programs/vec.flan | 18 ++- test/test_acceptance.ml | 4 +- test/test_flan.ml | 49 +++++++- test/test_valgrind.ml | 6 +- vendor/edn/provide.flan | 16 +-- vendor/edn/read.flan | 4 +- vendor/json/json.flan | 4 +- vendor/json/provide.flan | 16 +-- web/index.html | 2 +- 35 files changed, 408 insertions(+), 145 deletions(-) diff --git a/FIX.org b/FIX.org index ded3757..efd0249 100644 --- a/FIX.org +++ b/FIX.org @@ -4734,3 +4734,115 @@ So it is gone, with the call respelled, at the merge: sand.flan:121 says ~:log-warning~, the ~warning 4~ member and its paragraph are out of raylib.flan, and the ~constant~ line is out of bindings. Every member of every mapped enum now carries its prefix, with no exception. + +* One slice, and the warning moved to the push — 2026-09-21 +The ruling, in the author's words: + +#+begin_quote +merge them into one slice. as-slice goes away. +#+end_quote + +** Why a second name was the wrong shape +[as-slice] existed as a warning. check.ml said so beside it: a view of a Vec +is a borrow from storage a push, a put or a reserve may reallocate out from +under you — the explicit Zig/Odin contract spec-memory.md chose instead of a +borrow checker — and a different word at the call site was how a reader was +meant to be told. + +It does not work, for two reasons and the second is the one that decides it. + +The input type already determines the semantics completely. A Vec can only be +borrowed; a fixed array, a slice or a string can only be viewed. There is no +call site anywhere at which a reader would want to pick between the two +behaviours for one input, so the second name expressed no choice. It was a +label, not an operation. + +And it was a label in the wrong place. It warns at the moment the view is +taken, which is the one moment nothing is wrong: the view is correct when it +is made. The danger arrives later, at the push. A warning stapled to the safe +end of the story is a warning nobody reads at the unsafe end. + +** What landed +One [slice], over a fixed array, a slice, a string and now a Vec, at all three +arities. The Vec's half is [vec_slice] in check.ml; everything the previous +lane built — the backwards-literal refusal, the static out-of-range refusal +where a length is known, the runtime trap, single evaluation of a non-trivial +target, the zero-cost implicit length on a fixed array — is untouched, and a +Vec reaches none of the static ones because it has no static length to reach +them with. + +The merge is entirely in the checker. Neither backend has an arity case, a +type case, or a line about this: the Vec path builds the same +[flan_vec_as_slice] call [as-slice] built, and the C symbol keeps its name. + +*(slice v lo) was free.* The runtime already reads a [hi] of -1 as "to the +end", which is what the one-argument form passes, so the tail form passes the +caller's [lo] and the same -1. No slot, no length read, no second evaluation of +the target, nothing computed that was not computed before. The Vec had no +two-argument spelling only because the name it had was not the name that grew +the arities. + +*A Vec a call returned is refused, at every arity*, and for a different reason +than the array. [(slice (mk))] over an array dangles: the view outlives a +temporary the frame reuses. [(slice (make-vec))] does not dangle — the storage +a returned Vec owns lives until its allocator's free-all or destroy. What is +lost is the *owner*: the header is a temporary, so nothing can ever free that +block, and the program has written a leak it cannot spell the fix for. The +refusal says that rather than borrowing the array's sentence, and names the +[let] that keeps the owner. + +The refusal for the name itself is in [ordinary_call], after every table, so a +program that defines an [as-slice] of its own still reaches its own. It reads +for somebody who has never heard of the old name — "there is no as-slice" — +and writes the call back out with the arguments the reader wrote, spelling any +argument that is a name or a number and standing in for one that is not, so +the suggestion is always a form that compiles. + +** The warning, moved +docs/BUILT.md gains a section next to the Vec surface table, and the [push] +row points at it: a view of a Vec is invalidated by [push], [put] or [reserve], +nothing checks it, and the rule is to take the view again afterwards. +spec-memory.md's "Borrowing" says the same in its own register and drops the +old two-spelling line. + +** Investigated and NOT built: a live view at the push +The brief asked whether a [push] with a live view of the same Vec in scope is +detectable cheaply, and said to build it only if the obvious case is catchable +with no false positives. It is not, and the reason is not analysis cost. + +The decisive case is one line of *correct* code: + +#+begin_src flan + (reserve v 100) + (let [s (slice v)] + (push v 1) + (println (at s 0))) +#+end_src + +The reserve is exactly how a program says "this push will not reallocate", and +under the contract the spec chose that promise is the program's to make. Any +flag on this — error or warning — is a false positive by the language's own +semantics, not by an approximation the check settled for. The bar the brief +set therefore cannot be met by a cheaper check, because the obstacle is not +precision. + +The syntactic sketch is worth writing down so nobody re-derives it. To avoid +flagging the common and harmless [(let [s (slice v)] (println (len s)) +(push v 1))] — where the view is dead by the push — the check must find a use +of the view *after* the push, which is liveness. Textual order is not +execution order across an [if] or a loop; a [set] of the view's binding or a +shadowing of either name breaks it; and narrowing it to one straight-line +statement list to make the order real shrinks it to almost nothing. Meanwhile +static flow tracking was deliberately repealed on 2026-09-18, and this is a +borrow checker's question wearing a smaller hat. Two reasons to stop, and the +first one is sufficient on its own. + +What was built instead is the sentence, in the two places a reader meets the +operation that breaks the view. + +** Swept +Every spelling in the repo: lib/ (check.ml, prelude.ml, render.ml, shim.ml), +runtime/flan_rt.c comments, test/ (test_flan.ml, test_acceptance.ml, +test_valgrind.ml and fifteen programs), vendor/edn and vendor/json, web, +docs/BUILT.md, docs/overview.md, docs/SPIKE-DYNAMIC.md, spec-memory.md, +NEXT.md and syntax-sketch.flan. sand.flan never used it. diff --git a/NEXT.md b/NEXT.md index aeded27..9dadea0 100644 --- a/NEXT.md +++ b/NEXT.md @@ -483,7 +483,7 @@ sign-extended to i64 walks straight through them. It is behind `f.md.checks` lik and -O2 alike and off only when checks as a whole were asked off. **It owns nothing, and needed no analysis to say so.** The result is a `Types.Slice`, which is not move-only and -carries no allocator, so `free` refuses it by the rule it already had — the same rule that refuses `(as-slice v)`. +carries no allocator, so `free` refuses it by the rule it already had — the same rule that refuses `(slice v)`. **Where the promise is written.** `rl/font-recs` and `rl/font-glyphs` in `vendor/raylib/raylib.flan`, because that is the one place raylib's invariant (both arrays hold `glyph-count` entries) is knowable. `examples/text-rectangle-bounds.flan` @@ -1706,7 +1706,7 @@ What landed: `append`/`append-i64`/`append-f64` (the builder), `concat`, `join`, Tests: `programs/strings.flan`, `programs/format.flan`, `programs/algorithms.flan`, `programs/math2.flan`. `string-from-bytes`, refused in that block, turned out to already exist: `string` is a builtin and -`(string (as-slice v))` is the round trip. +`(string (slice v))` is the round trip. **What could not be built, and why each one could not.** All four want a compiler or runtime change, and none of them wants a language decision. @@ -1764,7 +1764,7 @@ to avoid rather than inherit. Four, with what to do instead. **1. One argument-order rule, held everywhere.** Clojure's sequence functions take the collection *last* (`(map f coll)`) and its collection functions take it *first* (`(assoc m k v)`). The split is deliberate there, and it is why Clojure needs **two** threading macros instead of one. **Our rule: the thing being operated on comes first.** -That is already what the language does — `(at a i)`, `(len xs)`, `(push v x)`, `(as-slice v)` — and `into` follows it +That is already what the language does — `(at a i)`, `(len xs)`, `(push v x)`, `(slice v)` — and `into` follows it with the source first. Hold it; do not ship two of anything to paper over a split. **2. A membership test says which thing it tests.** Clojure's `contains?` checks *keys*, so `(contains? [1 2 3] 1)` is @@ -2465,7 +2465,7 @@ expander last, on 6's unions. rather than a literal calling-convention parameter; both are stated as amendments in `docs/BUILT.md`. A user-written allocator is refused by name with milestone 5 as the reason. -~~2. **`(Vec T)`**~~ **Done**, over the type-erased runtime, with `push`, `reserve`, `at`, `len`, `as-slice`, `free` and +~~2. **`(Vec T)`**~~ **Done**, over the type-erased runtime, with `push`, `reserve`, `at`, `len`, `slice`, `free` and `clone`, and with move-only enforced by a dead set that unions at an `if` or a `match` join. `at` and `len` were extended rather than duplicated. The header is six words in *every* build, not four in release — a layout that changes with a build flag can disagree silently across the reload boundary — and that is the third amendment. diff --git a/docs/BUILT.md b/docs/BUILT.md index 38dc87c..283d7c5 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -2117,7 +2117,7 @@ hand-off dropping a number, which is what the note left behind said it was. **A `(Vec T)` shows as `` and a `(Ptr T)` as ``**, because that is what `render.ml` already does for them everywhere else: following a pointer a REPL was handed is not a safe thing to do on someone's behalf, and walking a -`Vec` structurally is a walk over storage the frame does not own — `(print (as-slice v))` is how that is asked for, +`Vec` structurally is a walk over storage the frame does not own — `(print (slice v))` is how that is asked for, and it says at the call site that it borrowed. Each slot is rendered **from its address** rather than copied into the thunk first. A copy would be one `alloca` the @@ -2424,14 +2424,30 @@ fires. | `context/allocator` / `context/temp` | the current implicit allocator, and the per-frame arena | | `(with-allocator a body...)` | rebinds for a dynamic extent and releases nothing | | `(vec-new T)` / `(vec-new T a)` / `(vec-new)` | a Vec, against the context or a named allocator | -| `(push v x)` / `(reserve v n)` | `()`, both | +| `(push v x)` / `(reserve v n)` | `()`, both. **Either may reallocate, and any `[T]` view of this `Vec` is stale afterwards** — see below | | `(at v i)` / `(len v)` | the array names, extended — not a parallel pair | -| `(as-slice v)` / `(as-slice v lo hi)` | a non-owning `[T]` view | +| `(slice v)` / `(slice v lo)` / `(slice v lo hi)` | a non-owning `[T]` view — the array names again, extended | | `(clone v)` / `(clone v a)` | the only copy; assignment moves | | `(free v)` | consumes its argument | | `(bytes s)` / `(bytes s a)` | a writable copy of a string's bytes, against the context or a named allocator — an allocating operation like `vec-new`: StorageExhausted with retry, a registry note in dev builds. The answer is a `[u8]` view of the block, so nothing can `free` it through the slice; it lives until its allocator's `free-all` or destroy | | `(bytes-view s)` | the string's own storage as a `[u8]`, costing nothing — the old `(bytes s)` reinterpret, renamed. Read-only by convention: a literal's view points into `.rodata` and a store through it traps | +### A view of a `Vec` goes stale at the `push`, and nothing checks it + +`(slice v)` copies a pointer and a length out of the `Vec` and keeps neither the `Vec` nor its allocator. `push`, +`put` and `reserve` are the three operations that may hand the block back and take a bigger one, and when one of them +does, every view taken before it holds the old address and the old length. Reading through such a view reads freed +memory; writing through it writes to memory the program no longer owns. **The compiler does not check this and will +not tell you.** That is `spec-memory.md`'s explicit Zig/Odin contract, chosen instead of a borrow checker, and it is +one rule: take the view again after the `push`. + +It is stated here, beside `push`, rather than at `slice`, because the `slice` is not the moment anything goes wrong — +the `push` is. There used to be a second name, `as-slice`, for taking a view of a `Vec`, on the theory that a +different word at that call site was the warning. It was not one. The value handed to `slice` already decides what +the result means — a `Vec` can only be borrowed, a fixed array or a string can only be viewed, and no call site ever +chooses between the two — so the second name expressed no choice a reader could make. One name, `slice`, over +everything that has elements; the warning is where it bites. + ### Three amendments to a frozen spec, and one addition **1. `free-all` is retain-capacity, and `arena-destroy` is the operation that hands pages back.** The spec's table has @@ -2512,7 +2528,7 @@ what was built and why. Move-only as a *type* property (what may be copied, what Reading a move-only local is a move unless the site said it was a borrow. That is the conservative direction: passing one to a function, binding it, returning it and `free`ing it are all moves and all reach one place, and the handful of -operations that only look at a container (`at`, `len`, `as-slice`, `push`, `reserve`, `clone`) say so. Only a +operations that only look at a container (`at`, `len`, `slice`, `push`, `reserve`, `clone`) say so. Only a *syntactically simple* target counts as a borrow — in `(len (f v))` the call still moves `v`. At an `if` and at a `match`, every arm is checked from the state before the form and the **union** of what they moved @@ -2539,7 +2555,7 @@ three shapes are refused where they are declared, each naming `drop`: - **a `Vec` of a `Vec`**, because the type-erased runtime copies and releases elements bytewise: `clone` would duplicate inner headers instead of copying what they own and `free` would drop their buffers. -And a `Vec` does not cross to C: handing a header that owns storage to C hands out an owner. `(as-slice v)` as +And a `Vec` does not cross to C: handing a header that owns storage to C hands out an owner. `(slice v)` as `(Ptr T)` plus `(len v)` is the shape that does cross, and the refusal says so. ### `StorageExhausted` went in *with* `Vec`, not after it @@ -3006,7 +3022,7 @@ a slice is invalidated by a `push`. The handle survives that and the pointer doe explicit Zig/Odin borrowing contract one level down, and it is worth naming rather than implying, because it reintroduces the silent-wrong-answer mode the handle just removed for anyone who keeps a resolved pointer across an insert. Chunked never-moving storage is the fix and it costs code; taking the contract is the smaller correct thing, -given `as-slice` already established it. +given `slice` already established it. ### `len` is the slot high-water and `live` is the count, in that direction @@ -3675,7 +3691,7 @@ be wrong in a way worth being able to see: the prelude *was* reaching the expand The list at the foot of `prelude.ml` used to be one sentence — every entry needed to produce bytes that did not exist in its input, and there was no allocator. `join`, `concat`, `split`, `to-lower`, `to-upper`, `repeat` and `replace` have moved up into the code; `string-from-bytes` turned out to be the `string` builtin all along, and -`(string (as-slice v))` is the round trip, free precisely because the layouts are identical. +`(string (slice v))` is the round trip, free precisely because the layouts are identical. What remains is refused for four different reasons, and is now written that way: `pad`/`center` for *nothing at all* except that no caller has asked; `format`/`sprintf` for variadics of mixed type; `map`/`filter`/`reduce`/`sort-by` diff --git a/docs/SPIKE-DYNAMIC.md b/docs/SPIKE-DYNAMIC.md index 015c7ac..8b3a771 100644 --- a/docs/SPIKE-DYNAMIC.md +++ b/docs/SPIKE-DYNAMIC.md @@ -335,7 +335,7 @@ this one too. **A typed `Vec` prints as `` and a dyn vec does not.** That looks like a mismatch and is not. The typed printer's refusal is about walking storage it does not own — `render.ml` says so, and directs you to -`(print (as-slice v))`, which borrows explicitly at the call site. A dyn vec's storage belongs to the +`(print (slice v))`, which borrows explicitly at the call site. A dyn vec's storage belongs to the collector, the printer is inside the runtime that owns it, and there is nobody to ask permission from. So a dyn vec prints the way a *slice* does, which is also the only rendering that carries any information. diff --git a/docs/overview.md b/docs/overview.md index e462c0d..9ab67ba 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -16,7 +16,7 @@ accurate**. Read instead: | "C-like with Roc syntax" | S-expressions, Clojure's brackets; C's value model | | "Start with interpreter, output C later" | Frontend → typed IR → LLVM IR as text → `clang`. An interpreter is acceptable for milestone 2 only | | "lists, slice, fixed-length array, matrices" | Four container types, distinct ownership: `[n T]`, `[T]`, `(Vec T)`, `(Map K V)` — see spec-memory.md | -| `arr[4]`, `arr[1..]` index syntax | `(at a i)`, `(as-slice a lo hi)` — no infix, no bracket indexing | +| `arr[4]`, `arr[1..]` index syntax | `(at a i)`, `(slice a lo hi)` — no infix, no bracket indexing | | `const by default?` | Locals are assignable places; parameters are not; `const` qualifies slices and pointers | | Rust-style iterator chains ending in `.collect()` | `->>` threading over slices; every collecting operation allocates from an explicit allocator, usually the frame arena | | `Ptr a` | Kept, as `(Ptr a)`. Cross-references use `(Handle a)` instead | diff --git a/lib/check.ml b/lib/check.ml index 1dd2449..fb5acd0 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -6005,6 +6005,74 @@ and vec_at ctx loc (target : Tast.expr) (idx : Ast.expr list) = "a Vec takes exactly one index — (at v i) — and its element is indexed \ separately" +(* [(slice v)], [(slice v lo)] and [(slice v lo hi)] over a Vec — the arm for + it is in [slice], and this is the half that differs from an array's. + + The result is a non-owning view: copying it copies ptr+len and never the + elements, and it carries no allocator, so freeing through one is not + expressible. What makes a Vec's view different from an array's is that the + storage it names can move — a [push], a [put] or a [reserve] may reallocate + and leave the view addressing the old block. Nothing checks that, which is + the explicit Zig/Odin contract spec-memory.md chose over a borrow checker, + and it is written down where a reader meets it: beside [push] in BUILT.md + and in spec-memory.md's "Borrowing". + + It used to be spelled [as-slice], on the theory that a second name warns + about that. It does not: the input type already decides which of the two + things happens, so there was no choice at the call site for the name to + express — and the moment it warned about was the moment the view is taken, + while the danger arrives later, at the push. + + No slot and no length read at any arity: -1 is the runtime's "to the end", + so the short forms pass a constant where an array passes its length, and + the target appears exactly once in all three. *) +and vec_slice ctx ~want loc (target : Tast.expr) elem (bounds : Ast.expr list) = + (* The mirror of the array rule one screen down, for a different reason. An + array a call returned is storage the frame reuses, so the view dangles; + a Vec a call returned owns storage that outlives the expression, and what + is lost is the owner — the header is a temporary, so nothing can ever + [free] it and the block lives to the allocator's free-all or destroy. A + [let] keeps the owner, so that is what the refusal names. *) + (match target.Tast.e with + | Tast.Call _ | Tast.CallPtr _ -> + fail loc + "this slices a Vec a call returned, and the header a call answers is a \ + temporary — the view outlives it, and the storage it names can no \ + longer be freed, because there is no Vec left to free it through. \ + Bind it first: (let [v (…)] (slice v …))" + | _ -> ()); + let int k = mk loc index_ty (Tast.Int (k, Types.I32)) in + (* -1 is "to the end". A Vec's length is not static, so unlike an array + there is no constant to fold and the runtime reads the length word it is + carrying anyway. *) + let to_end () = int (-1L) in + let lo, hi = + match bounds with + | [] -> int 0L, to_end () + | [ lo ] -> index_expr ctx lo, to_end () + | [ lo; hi ] -> index_expr ctx lo, index_expr ctx hi + | _ -> assert false + in + (* The same refusal the other targets get. There is no static length to + check a single bound against, but a literal pair that runs backwards is + wrong without one — and [hi] here may be the -1 sentinel, which is not a + bound anybody wrote. *) + (match literal lo, literal hi with + | Some a, Some b when b >= 0L && a > b -> + fail loc "slice [%Ld %Ld) runs backwards — lo must not exceed hi" a b + | _ -> ()); + let out = fresh_slot ctx (Types.Slice elem) in + let fill = + rt loc Types.Unit "flan_vec_as_slice" + [ target; addr_of loc (mk loc (Types.Slice elem) (Tast.Local out)); + lo; hi; size_of loc elem; here loc ] + in + expect ctx loc ~want + (mk loc (Types.Slice elem) + (Tast.Let ([ (out, mk loc (Types.Slice elem) + (Tast.Zero (Types.Slice elem))) ], + [ fill; mk loc (Types.Slice elem) (Tast.Local out) ]))) + (* Every arm below is a name an editor can be asked about and no program ever wrote down, so each one needs a line in [builtins] further down this file. A new arm without an entry fails the build — test_flan reads both. *) @@ -6689,37 +6757,6 @@ and named_call ?(qualified = false) ctx ~want loc name args = (region_check ctx.env loc target (with_note loc (alloc_guard ctx loc attempt) note)) | _ -> assert false) - (* (as-slice v) and (as-slice v lo hi) — spec-memory.md, "Borrowing". The - result is a non-owning view: copying it copies ptr+len and never the - elements, and it carries no allocator, so freeing through one is not - expressible. A push, a put or a reserve may invalidate it; that is the - explicit Zig/Odin contract the spec chose over a borrow checker. *) - | "as-slice" -> - (match args with - | target :: rest when List.length rest = 0 || List.length rest = 2 -> - let target = check ctx target in - let elem = vec_elem loc "as-slice" target.Tast.ty in - let lo, hi = - match rest with - | [] -> - mk loc index_ty (Tast.Int (0L, Types.I32)), - (* -1 is "to the end": (as-slice v) has no static length to pass. *) - mk loc index_ty (Tast.Int (-1L, Types.I32)) - | [ lo; hi ] -> index_expr ctx lo, index_expr ctx hi - | _ -> assert false - in - let out = fresh_slot ctx (Types.Slice elem) in - let fill = - rt loc Types.Unit "flan_vec_as_slice" - [ target; addr_of loc (mk loc (Types.Slice elem) (Tast.Local out)); - lo; hi; size_of loc elem; here loc ] - in - expect ctx loc ~want - (mk loc (Types.Slice elem) - (Tast.Let ([ (out, mk loc (Types.Slice elem) - (Tast.Zero (Types.Slice elem))) ], - [ fill; mk loc (Types.Slice elem) (Tast.Local out) ]))) - | _ -> fail loc "as-slice is (as-slice v) or (as-slice v lo hi)") (* spec-memory.md's first release point. Since the repeal, what it consumes it consumes at run time only: nothing marks the binding dead, so a second [free] or a read after this one type-checks and misbehaves at run time — @@ -7476,7 +7513,13 @@ and named_call ?(qualified = false) ctx ~want loc name args = The target is read twice when [hi] is the implicit length, so a target that is not already a name goes into a slot first: [(slice (f x))] must call [f] once. An array target never needs the slot, because its length - is a constant and the target appears exactly once. *) + is a constant and the target appears exactly once. + + One name over every target that has elements, a Vec included. There used + to be a second, [as-slice], for the Vec alone; the input type already + decides which semantics apply — a Vec can only be borrowed, an array can + only be viewed, and no call site chooses — so the second name expressed + nothing and is gone. [vec_slice] above is the Vec's half. *) | "slice" -> (match args with | [] | _ :: _ :: _ :: _ :: _ -> @@ -7486,6 +7529,11 @@ and named_call ?(qualified = false) ctx ~want loc name args = | target :: bounds -> let target = check ctx target in let ty = target.Tast.ty in + match ty with + (* A Vec leaves here: everything below is written around a length the + compiler can see, and a Vec's is a word the runtime reads. *) + | Types.Vec elem -> vec_slice ctx ~want loc target elem bounds + | _ -> (* A string slices to a string, not to a [u8]: the result views the same bytes and is read-only for the same reason the source is, and calling it a byte slice would hand out a writable-looking view of @@ -7494,7 +7542,8 @@ and named_call ?(qualified = false) ctx ~want loc name args = | Types.Array (_, t) | Types.Slice t -> Types.Slice t | Types.String -> Types.String | other -> - fail loc "slice takes an array, a slice or a string, found %s" + fail loc + "slice takes an array, a slice, a string or a Vec, found %s" (Types.to_string other) in (* An array that came back from a call is a value in a temporary this @@ -7582,7 +7631,7 @@ and named_call ?(qualified = false) ctx ~want loc name args = from. **It owns nothing.** The result is a [Types.Slice], which carries no - allocator and is the same non-owning view (as-slice v) answers — so + allocator and is the same non-owning view (slice v) answers — so [free] refuses it by the rule it already had ("free takes an owning container"). *) | "slice-from-ptr" -> @@ -7690,7 +7739,7 @@ and named_call ?(qualified = false) ctx ~want loc name args = The lowering mirrors [vec-new]: a hidden (Vec u8) temp holds the block so the allocation registry can read its extent, the attempt sits under [alloc_guard] so a failure signals StorageExhausted with retry, and the - answer is the [as-slice] of the whole of it. The slice carries no + answer is the [slice] of the whole of it. The slice carries no allocator, so nothing can [free] this block through it — it lives until its allocator's free-all or destroy, which is the story every borrowed view already has and is written down in BUILT.md's surface table. @@ -8095,6 +8144,41 @@ and ordinary_call ctx ~want loc name args = else if String.contains name '/' then unimplemented loc (Printf.sprintf "the call %s into an imported package" name) 4 + else if name = "as-slice" then + (* A name nothing defines, near enough to [slice] to be worth a + sentence rather than a did-you-mean: whatever [slice] is given + decides what the view means, so there is one word for it and this + says which. Asked here, after every table, so that a program that + defines an [as-slice] of its own still reaches its own. *) + (* The call is written back out rather than described, and every + argument the reader wrote that can be spelled is spelled. One that + cannot — a call, a field, anything with structure — becomes the + name it stands for, so the suggestion is always a form that + compiles rather than a form with a hole in it. *) + let spell stand_for (a : Ast.expr) = + match a.Ast.e with + | Ast.Var v -> v + | Ast.Int n -> Int64.to_string n + | _ -> stand_for + in + let call = + match args with + | [] -> "(slice v)" + | t :: bounds -> + let names = [ "lo"; "hi" ] in + let bounds = + List.mapi + (fun i b -> + " " ^ spell (try List.nth names i with _ -> "n") b) + bounds + in + "(slice " ^ spell "v" t ^ String.concat "" bounds ^ ")" + in + Loc.failk "check/unknown-function" loc + "there is no as-slice. slice takes the view, and what it is given \ + says what the view is: over a Vec it borrows the storage the Vec \ + owns, over an array or a string it looks at the value itself. \ + Write %s" call else (* The did-you-mean comes first, and for a capitalised head it is asked of the *type* tables as well: [(Piont 1 2)] with [Point] declared is @@ -8388,7 +8472,7 @@ and generic_call ctx ~want loc name vars pats pret args = [dyn] is an ordinary case of [Types.t], so it substituted like any other type and a copy was generated at it. The copy then reached whatever the body did with the value, and the dyn answers are not all there — [(Option - dyn)] has no descriptor the collector can find, [as-slice] over a + dyn)] has no descriptor the collector can find, [slice] over a [(Vec dyn)] refuses. So the refusal existed, it just arrived from inside the generic's own source: [(or-else (Some d) e)] over two dyns is reported against [:385], a line the caller did not write and cannot act @@ -8939,10 +9023,6 @@ let builtins : (string * string * string) list = "Makes room for n more. For a map the number is entries rather than \ slots — the block is sized so that n still sits under the load \ factor."); - ("as-slice", "as-slice [(Vec T) i32? i32?] [T]", - "A non-owning view of the whole Vec, or of the half-open range \ - [lo hi). It carries no allocator, and a push, a put or a reserve may \ - invalidate it."); ("free", "free [(Vec T)|(Map K V)] ()", "Releases the container's block. It does not recurse into elements that \ own storage — such a container is refused here, and releasing its \ @@ -9029,11 +9109,13 @@ let builtins : (string * string * string) list = allocator's epoch checked first. On a string it is the byte, a u8. It \ is also a place, so (set (at v i) x) goes through the same check; a \ string is the exception, being a view it does not own."); - ("slice", "slice [[n T]|[T]|string i32? i32?] [T]|string", + ("slice", "slice [[n T]|[T]|string|(Vec T) i32? i32?] [T]|string", "The half-open range [lo hi) as a non-owning view. lo defaults to 0 and \ hi to the length, so (slice a) is the whole of it and (slice a n) is \ the tail from n. A bound may sit one past the end; a literal pair that \ - runs backwards is refused here. A string slices to a string."); + runs backwards is refused here. A string slices to a string. A view of \ + a Vec is a borrow from storage the Vec owns, and a push, a put or a \ + reserve on that Vec may invalidate it."); ("slice-from-ptr", "slice-from-ptr [(Ptr T) i32] [T]", "Puts a length on a pointer that came back from C. The caller promises \ it addresses that many initialised T and that they outlive the result; \ diff --git a/lib/prelude.ml b/lib/prelude.ml index 163fdd3..60968a4 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -231,7 +231,7 @@ let source = {flan| ;; to return a new sequence from — and it stays the right shape now that there ;; is one, because sorting a thing you already own should not allocate. The ;; allocating tier is further down, and a caller sorts a Vec by sorting -;; (as-slice v). +;; (slice v). ;; ;; **map, filter, reduce and a sort taking a comparator are here now**, in a ;; section of their own after the f32 family. They were blocked on *function @@ -1816,7 +1816,7 @@ let source = {flan| ;; stopped being true when `Vec` landed, and most of the list has moved up into ;; the building section above: join, concat, split, to-lower, to-upper, repeat ;; and replace are all written now, and `string-from-bytes` turned out to be -;; the `string` builtin all along — (string (as-slice v)) is the round trip, +;; the `string` builtin all along — (string (slice v)) is the round trip, ;; and the layouts being identical is exactly why it is free. ;; ;; What is left is refused for four *different* reasons, which is why they are @@ -2025,14 +2025,14 @@ let source = {flan| ;; change this, and it does not exist. (defn form-nil [] [Form] (let [v (vec-new Form)] - (as-slice v))) + (slice v))) (defn form-cons [x Form rest [Form]] [Form] (let [v (vec-new Form)] (push v x) (dotimes [i (len rest)] (push v (at rest i))) - (as-slice v))) + (slice v))) (defn form-append [a [Form] b [Form]] [Form] (let [v (vec-new Form)] @@ -2040,7 +2040,7 @@ let source = {flan| (push v (at a i))) (dotimes [i (len b)] (push v (at b i))) - (as-slice v))) + (slice v))) ;; The rest of a macro's arguments, which is what a variadic body is: a macro ;; takes one parameter, the slice of the forms at its call site. @@ -2050,7 +2050,7 @@ let source = {flan| (while (< i (len xs)) (push v (at xs i)) (set i (+ i 1))) - (as-slice v))) + (slice v))) ;; The elements of a vector form, which is what a [ ] pattern in a macro's ;; parameter list unwraps. The other arm is unreachable from a generated @@ -2083,7 +2083,7 @@ let source = {flan| (let [d (i64->bytes gensym-n)] (dotimes [i (len d)] (push v (at d i)))) - (Form.Sym {.s (string (as-slice v))}))) + (Form.Sym {.s (string (slice v))}))) ;; ── The first special form to stop being one ────────────────────────── ;; diff --git a/lib/render.ml b/lib/render.ml index ad83dd8..5f92f8b 100644 --- a/lib/render.ml +++ b/lib/render.ml @@ -208,8 +208,8 @@ let rec render c depth (e : Tast.expr) : Tast.expr list = would make an acceptance test's output depend on the heap. *) | Types.Alloc -> [ lit "" ] (* Printing a Vec structurally would be a walk over storage this function - does not own, and the walk is what [as-slice] is for: (print (as-slice - v)) prints the elements and says at the call site that it borrowed. *) + does not own, and the walk is what [slice] is for: (print (slice v)) + prints the elements and says at the call site that it borrowed. *) | Types.Vec _ -> [ lit "" ] | Types.Fn _ as ft -> [ lit ("<" ^ Types.to_string ft ^ ">") ] | Types.Option t -> diff --git a/lib/shim.ml b/lib/shim.ml index 4e65881..ccad00d 100644 --- a/lib/shim.ml +++ b/lib/shim.ml @@ -248,7 +248,7 @@ let rec cty env ~needed ~loc ~what (t : Ast.texpr) : string = | Ast.Tapp ("Vec", _) -> fail loc "%s is a Vec, which owns its storage — handing its header to C hands out \ - an owner. Pass (as-slice v) as (Ptr T) and (len v), the same shape a \ + an owner. Pass (slice v) as (Ptr T) and (len v), the same shape a \ slice crosses in" what | Ast.Tfn _ -> diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 0e8a99d..9a3aed9 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -1802,7 +1802,7 @@ void *flan_vec_at(flan_vec *v, int32_t i, int64_t size, const uint8_t *loc, return (uint8_t *)v->ptr + (int64_t)i * size; } -/* [hi] of -1 means "to the end": (as-slice v) has no static length to write. */ +/* [hi] of -1 means "to the end": (slice v) has no static length to write. */ void flan_vec_as_slice(flan_vec *v, void *out, int32_t lo, int32_t hi, int64_t size, const uint8_t *loc, int64_t loclen, void *xfer) { @@ -2692,7 +2692,7 @@ int64_t flan_map_len(flan_map *m, const uint8_t *loc, int64_t loclen) { * backward shift moves entries to lower slots, and a cursor already past them * steps over entries it has not answered. The epoch check below catches a * released arena and nothing catches either of these, which is the same - * bargain [as-slice] already makes. + * bargain [slice] already makes. * * The layout is the one the geometry describes and is worth restating because * it is the thing most likely to be got wrong here: [data] is *one* allocation diff --git a/spec-memory.md b/spec-memory.md index d869b2b..9ced367 100644 --- a/spec-memory.md +++ b/spec-memory.md @@ -98,12 +98,18 @@ region" for the rule that stands in its place and for what it costs. ## Borrowing -- `(as-slice v)` / `(as-slice v lo hi)` view a `Vec` or fixed array as `[T]`. +- `(slice x)` / `(slice x lo)` / `(slice x lo hi)` view a `Vec`, a fixed array, + a slice or a string. One name, because the thing handed to it decides what + the view means: a `Vec` can only be borrowed, an array can only be viewed, + and no call site picks between the two. +- **A view of a `Vec` is invalidated by `push`, `put` or `reserve` on that + `Vec`, and nothing checks it.** Those are the operations that may reallocate, + and a view holds the old address and the old length. This is the whole of the + contract: the view goes stale at the `push`, not at the `slice`, and it is + the program's business to take the view again afterwards. - **The first implementation follows Zig/Odin's explicit model, not Rust's - borrow checker.** A slice is invalidated by any operation that may reallocate - its owner (`push`, `put`, `reserve`); its user is responsible for respecting - that contract. Dev builds carry a generation word on `Vec` and trap on use of - a stale slice. `Ptr` is the explicit lower-level escape hatch and has the + borrow checker.** Dev builds carry a generation word on `Vec` and trap on use + of a stale slice. `Ptr` is the explicit lower-level escape hatch and has the same lifetime contract. - A future lightweight provenance pass may reject the obvious mistakes (a borrow of a local escaping, use after an owner moves, and reallocation with a diff --git a/syntax-sketch.flan b/syntax-sketch.flan index 32462cb..bd1eacf 100644 --- a/syntax-sketch.flan +++ b/syntax-sketch.flan @@ -211,7 +211,7 @@ ;; individually. `each` borrows it as a slice. (defn draw-frame [w (Ptr World) dt f32] () (with-allocator context/temp - (->> (as-slice (.enemies w)) + (->> (slice (.enemies w)) (filter (fn [e] (on-screen? (.pos e)))) (each (fn [e] (rl/draw-texture (.spr e) (.pos e)))))) (free-all context/temp)) diff --git a/test/programs/algorithms.flan b/test/programs/algorithms.flan index 23abeb8..d86f288 100644 --- a/test/programs/algorithms.flan +++ b/test/programs/algorithms.flan @@ -106,15 +106,15 @@ ;; the bytes never do, so this sorts a borrowed view of a string literal -- ;; which an in-place byte sort could not, since a literal lives in .rodata. (let [f (split (bytes-view "pear,apple,Fig,apple,banana") \,)] - (sort-bytes (as-slice f)) - (show-fields (as-slice f)) ; Fig apple apple banana pear + (sort-bytes (slice f)) + (show-fields (slice f)) ; Fig apple apple banana pear (free f)) ;; And the round trip the whole second tier is for: split, sort, join. (let [f (split (bytes-view "delta,alpha,charlie,bravo") \,)] - (sort-bytes (as-slice f)) - (let [j (join (as-slice f) (bytes-view " < "))] - (println (string (as-slice j))) ; alpha < bravo < charlie < delta + (sort-bytes (slice f)) + (let [j (join (slice f) (bytes-view " < "))] + (println (string (slice j))) ; alpha < bravo < charlie < delta (free j)) (free f)) 0) diff --git a/test/programs/bounds-condition.flan b/test/programs/bounds-condition.flan index 99a6c1f..42cccff 100644 --- a/test/programs/bounds-condition.flan +++ b/test/programs/bounds-condition.flan @@ -40,7 +40,7 @@ ;;;; is why there is one condition type and not two. ;;;; 4. Every route to a bad index signals: reading a fixed array, writing ;;;; one (a different lowering — place/Pindex, not At), a slice, a Vec -;;;; element, and a Vec's as-slice. A Vec's check lives inside the +;;;; element, and a Vec's slice. A Vec's check lives inside the ;;;; runtime rather than in emitted IR, so those two are plumbed ;;;; separately and are the ones most likely to be left behind. @@ -166,11 +166,11 @@ (show "low" low) (show "length" length) (restart-case - (do (show "vec-slice" (i64 (len (as-slice v 0 2)))) + (do (show "vec-slice" (i64 (len (slice v 0 2)))) (set frames (+ frames 1))) (continue [] (set skipped (+ skipped 1)))) (restart-case - (do (show "vec-slice" (i64 (len (as-slice v 0 9)))) + (do (show "vec-slice" (i64 (len (slice v 0 9)))) (set frames (+ frames 1))) (continue [] (set skipped (+ skipped 1)))) (show "high" high)) diff --git a/test/programs/edn-read.flan b/test/programs/edn-read.flan index 590f555..0e3f9ab 100644 --- a/test/programs/edn-read.flan +++ b/test/programs/edn-read.flan @@ -67,11 +67,11 @@ ;; A document in a buffer this program owns and can write to. (bytes-view "literal") ;; is not that — a literal is constant data behind a writable-looking slice — -;; so the source is built with append and the write goes through as-slice. +;; so the source is built with append and the write goes through slice. (defn survives-its-buffer [] () (let [buf (vec-new u8)] (append (addr buf) (bytes-view "{:name \"level-1\" :xs [1 2]}")) - (let [src (as-slice buf) + (let [src (slice buf) v (edn/read src)] (dotimes [i (len src)] (set (at src i) \x)) diff --git a/test/programs/format.flan b/test/programs/format.flan index 6fdb5e1..dfec99b 100644 --- a/test/programs/format.flan +++ b/test/programs/format.flan @@ -11,7 +11,7 @@ (defn show [x f64 p i32] () (let [v (format-f64 x p)] - (println (string (as-slice v))) + (println (string (slice v))) (free v))) (defn main [] i32 @@ -89,12 +89,12 @@ (let [b (vec-new u8)] (append (addr b) (bytes-view "fps ")) (let [f (format-f64 59.94 1)] - (append (addr b) (as-slice f)) + (append (addr b) (slice f)) (free f)) (append (addr b) (bytes-view " / frame ")) (let [f (format-f64 0.0166667 4)] - (append (addr b) (as-slice f)) + (append (addr b) (slice f)) (free f)) - (println (string (as-slice b))) ; fps 59.9 / frame 0.0167 + (println (string (slice b))) ; fps 59.9 / frame 0.0167 (free b)) 0) diff --git a/test/programs/generics.flan b/test/programs/generics.flan index 83f7aab..853d4cf 100644 --- a/test/programs/generics.flan +++ b/test/programs/generics.flan @@ -246,8 +246,8 @@ (let [a (arena-new 4096) keep (filter (slice ns 0 4) (fn [x] (> x 5))) one (one-of 4.5)] - (println (len (as-slice keep))) - (println (at (as-slice one) 0)) + (println (len (slice keep))) + (println (at (slice one) 0)) (free one) (free keep) (free-all a)))) diff --git a/test/programs/global-init.flan b/test/programs/global-init.flan index b45371c..8b0a121 100644 --- a/test/programs/global-init.flan +++ b/test/programs/global-init.flan @@ -73,7 +73,7 @@ ;; program's when main runs. (push names 11) (push names 22) - (println (len (as-slice names))) + (println (len (slice names))) (println (at names 1)) (free names) ;; And the arena, used the way sand.flan means to use it. diff --git a/test/programs/init-conditions.flan b/test/programs/init-conditions.flan index d89827a..6d80e07 100644 --- a/test/programs/init-conditions.flan +++ b/test/programs/init-conditions.flan @@ -13,7 +13,7 @@ ;;;; (defn read-file [path string] dyn ;;;; (let [src (slurp path (heap-allocator))] ;;;; (defer (free src)) -;;;; (read (as-slice src)))) +;;;; (read (slice src)))) ;;;; ;;;; `slurp` signals FileError and the handler further out unwinds. The ;;;; transfer leaves this frame through its defers — and `src` was never diff --git a/test/programs/into.flan b/test/programs/into.flan index 50a42d2..34aad46 100644 --- a/test/programs/into.flan +++ b/test/programs/into.flan @@ -46,20 +46,20 @@ ;; No transforms: a copy into the destination named in the form. (let [xs [7 8 9] v (into xs (vec-new i32))] - (show (as-slice v)) ; 7 8 9 + (show (slice v)) ; 7 8 9 (free v)) ;; map then filter. (let [xs [1 2 3 4 5 6] v (into xs (vec-new i32) (map double) (filter even?))] - (show (as-slice v)) ; 2 4 6 8 10 12 + (show (slice v)) ; 2 4 6 8 10 12 (free v)) ;; filter then map, over the same source: a different answer, because the ;; stages are in the order they were written. (let [xs [1 2 3 4 5 6] v (into xs (vec-new i32) (filter even?) (map double))] - (show (as-slice v)) ; 4 8 12 + (show (slice v)) ; 4 8 12 (free v)) ;; One pass and no intermediate collection. The two chains above pulled @@ -69,8 +69,8 @@ ;; A name as the source is read, not moved: src is still alive here. (let [src (into [3 1 2] (vec-new i32)) v (into src (vec-new i32) (map double))] - (show (as-slice v)) ; 6 2 4 - (show (as-slice src)) ; 3 1 2 + (show (slice v)) ; 6 2 4 + (show (slice src)) ; 3 1 2 (free v) (free src)) @@ -88,7 +88,7 @@ ;; elements come out of it. (let [xs [1 2 3 4] v (into (source (slice xs 0 4)) (vec-new i32) (filter even?))] - (show (as-slice v)) ; 2 4 + (show (slice v)) ; 2 4 (free v)) (print builds) (println "") ; 1 0) diff --git a/test/programs/json.flan b/test/programs/json.flan index 220dcf5..925a750 100644 --- a/test/programs/json.flan +++ b/test/programs/json.flan @@ -407,7 +407,7 @@ (let [buf (vec-new u8)] (append (addr buf) (bytes-view doc)) (with-allocator frame - (let [v (read-doc (as-slice buf))] + (let [v (read-doc (slice buf))] (println (describe v)) ; object (println (count-leaves v)) ; every leaf in the graph (println (sum-ints v)) ; [1 2 3] diff --git a/test/programs/shim-nul.flan b/test/programs/shim-nul.flan index 64e3d31..e2bd18e 100644 --- a/test/programs/shim-nul.flan +++ b/test/programs/shim-nul.flan @@ -22,7 +22,7 @@ (push v 104) ; h (push v 105) ; i (println "before") - (c-puts (string (as-slice v))) + (c-puts (string (slice v))) (println "unreachable") (free v)) 0) diff --git a/test/programs/slurp.flan b/test/programs/slurp.flan index 1182a6b..f07f1e2 100644 --- a/test/programs/slurp.flan +++ b/test/programs/slurp.flan @@ -29,7 +29,7 @@ ;; ── The happy path ──────────────────────────────────────────────── (let [v (slurp "programs/assets/a.txt")] (println (len v)) ; 13 - (print (string (as-slice v))) ; hello from a + (print (string (slice v))) ; hello from a (free v)) ;; Byte-exact, the same as an embed: nothing here decodes anything. @@ -52,7 +52,7 @@ (invoke-restart 'use-value "programs/assets/b.bin"))] (let [v (slurp "programs/assets/does-not-exist")] (println (len v)) ; 3 - (println (string (as-slice v))) ; BBB + (println (string (slice v))) ; BBB (free v))) (println seen) ; 1 (println (= last-reason file-missing)) ; true @@ -65,7 +65,7 @@ (barf "slurp-out.txt" (bytes-view "round trip\n")) (let [v (slurp "slurp-out.txt")] (println (len v)) ; 11 - (print (string (as-slice v))) ; round trip + (print (string (slice v))) ; round trip (free v)) ;; barf's own failure signals the same condition with op = write. A directory @@ -80,7 +80,7 @@ (println seen) ; 1 (println (= last-op file-op-write)) ; true (let [v (slurp "slurp-out.txt")] - (print (string (as-slice v))) ; second + (print (string (slice v))) ; second (free v)) ;; ── retry: the file was not there, so the handler makes it ──────── @@ -96,7 +96,7 @@ (barf "slurp-made.txt" (bytes-view "made by the handler\n")) (invoke-restart 'retry))] (let [v (slurp "slurp-made.txt")] - (print (string (as-slice v))) ; made by the handler + (print (string (slice v))) ; made by the handler (free v))) (println seen) ; 1 (println (= last-reason file-missing)) ; true diff --git a/test/programs/string-eq.flan b/test/programs/string-eq.flan index 8df7238..383c8e1 100644 --- a/test/programs/string-eq.flan +++ b/test/programs/string-eq.flan @@ -22,7 +22,7 @@ ;; fire -- what answers here is the byte loop, or the length check first ;; ruling nothing out since both are three bytes. (let [heap (to-lower (bytes-view "ABC"))] - (let [h (string (as-slice heap))] + (let [h (string (slice heap))] (println (= "abc" h)) ; true (println (!= "abc" h))) (free heap)) diff --git a/test/programs/strings.flan b/test/programs/strings.flan index df1a057..e15ea35 100644 --- a/test/programs/strings.flan +++ b/test/programs/strings.flan @@ -9,10 +9,10 @@ ;;;; behaviour (spec-memory.md), because this file is the example people copy. ;;; A (Vec u8) printed as text, without the caller writing the two-step every -;;; time. as-slice borrows -- it copies ptr+len and never the elements -- so v +;;; time. slice borrows -- it copies ptr+len and never the elements -- so v ;;; is still the owner afterwards and is still free-able. (defn show [v (Ptr (Vec u8))] () - (println (string (as-slice (deref v))))) + (println (string (slice (deref v))))) (defn main [] i32 ;; The builder. Three appends and two numbers into one Vec, which is the @@ -130,7 +130,7 @@ ;; different one, so an implementation that handed back the original slice ;; would print the original string. (let [f (split (bytes-view "a,b,c") \,)] - (let [j (join (as-slice f) (bytes-view "/"))] + (let [j (join (slice f) (bytes-view "/"))] (show (addr j)) ; a/b/c (free j)) (free f)) diff --git a/test/programs/vec-global.flan b/test/programs/vec-global.flan index 202a28f..2c6c1c4 100644 --- a/test/programs/vec-global.flan +++ b/test/programs/vec-global.flan @@ -48,7 +48,7 @@ ;; The second run. Nothing re-initialises the global between them, so the ;; length keeps climbing and the loaded data is the same block it was. (entry) - (println (len (as-slice the-data))) + (println (len (slice the-data))) ;; A copy is the one thing something else may own, and freeing that copy ;; leaves the global untouched. (let [c (clone the-data)] diff --git a/test/programs/vec.flan b/test/programs/vec.flan index bc7193c..732d8bd 100644 --- a/test/programs/vec.flan +++ b/test/programs/vec.flan @@ -42,12 +42,18 @@ (set (at v 1) 99) (println (at v 1)) ; 99 - ;; as-slice is a non-owning view: it copies ptr+len and never the + ;; slice over a Vec is a non-owning view: it copies ptr+len and never the ;; elements, and it carries no allocator, so nothing can be freed through - ;; one. [at] and [len] over it are the array operations, unchanged. - (println (sum (as-slice v))) ; 139 - (println (len (as-slice v 1 3))) ; 2 - (println (at (as-slice v 1 3) 0)) ; 99 + ;; one. [at] and [len] over it are the array operations, unchanged, and + ;; the three arities are the ones every other target has -- the tail form + ;; included, which the Vec had no spelling for while it had a name of its + ;; own. The view is of storage v owns: a push here would move it, and + ;; nothing would say so. + (println (sum (slice v))) ; 139 + (println (len (slice v 1 3))) ; 2 + (println (at (slice v 1 3) 0)) ; 99 + (println (len (slice v 1))) ; 2 + (println (at (slice v 1) 1)) ; 30 ;; clone is the only copy: assignment moves. The copy is independent, and ;; freeing it leaves the original alone. @@ -65,7 +71,7 @@ (println (len v)) ; 4 ;; The structural printer reaches both new types. Neither is followed: a - ;; Vec's elements are printed through (as-slice v), which says at the call + ;; Vec's elements are printed through (slice v), which says at the call ;; site that it borrowed, and an allocator's contents are the runtime's and ;; its address is not stable across runs. (println v) ; diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 41326a5..56bbc35 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -1344,7 +1344,7 @@ let () = moves are here too — into a call and out of one — because a Vec that cannot be handed to a function is not a container anyone can use. *) let vec_out = - "0\n3\n10\n30\n99\n139\n2\n99\n-1\n10\n3\n4\n\ + "0\n3\n10\n30\n99\n139\n2\n99\n2\n30\n-1\n10\n3\n4\n\ \n\n2\n4\n7\nfalse\n11\n5\n" in outputs "vec" "programs/vec.flan" vec_out; @@ -2392,7 +2392,7 @@ let () = Three claims, and the second is the one that had to be decided rather than inherited. (1) Five frames finish and seven are abandoned, out of five routes to a bad index — fixed-array read, fixed-array write (a - different lowering), slice, Vec element and Vec as-slice, the last two + different lowering), slice, Vec element and Vec slice, the last two checked inside the runtime rather than in emitted IR. (2) `cleaned` is 5, which is every defer on every one of those paths: an answered bounds failure leaves through the same unwind path a `return` uses and runs diff --git a/test/test_flan.ml b/test/test_flan.ml index 2cf9eaa..5fbf903 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -1959,9 +1959,50 @@ let () = ~needle:"(slice a lo hi)"; rejects_check "slice of four" (arr ^ "(defn f [] [i32] (slice a 0 1 2))") ~needle:"given 4 arguments"; - rejects_check "slice of a Vec" - "(defn f [v (Vec i32)] [i32] (slice v))" - ~needle:"slice takes an array, a slice or a string"; + rejects_check "slice of something with no elements" + "(defn f [n i32] [i32] (slice n))" + ~needle:"slice takes an array, a slice, a string or a Vec"; + (* A Vec is the fourth thing slice takes, and it is the one that borrows: + the view is of storage the Vec owns and a push may move it. That is a + rule about the push and is written down beside the push; here what is + pinned is that all three arities reach a Vec, the two-argument one + included — it had no spelling at all while the Vec had a name of its + own. There is no static length to check a bound against, so the only + refusal left is the literal pair that runs backwards. *) + let vec = "(defn f [v (Vec i32)] " in + accepts "the whole of a Vec" (vec ^ "[i32] (slice v))"); + accepts "the tail of a Vec" (vec ^ "[i32] (slice v 2))"); + accepts "a range of a Vec" (vec ^ "[i32] (slice v 1 3))"); + accepts "a Vec bound is not known here" (vec ^ "[i32] (slice v 0 9999))"); + rejects_check "reversed slice of a Vec" (vec ^ "[i32] (slice v 2 1))") + ~needle:"runs backwards"; + rejects_check "slice of a Vec of four" + (vec ^ "[i32] (slice v 0 1 2))") ~needle:"given 4 arguments"; + (* The mirror of the returned-array refusal, for a different loss. The + storage a returned Vec owns outlives the expression, so the view does not + dangle — what is gone is the owner, and with it any way to free the + block. *) + rejects_check "slice of a returned Vec" + "(defn mk [] (Vec i32) (vec-new i32)) (defn f [] [i32] (slice (mk)))" + ~needle:"the header a call answers is a temporary"; + rejects_check "slice of a returned Vec, three arguments" + "(defn mk [] (Vec i32) (vec-new i32)) (defn f [] [i32] (slice (mk) 0 1))" + ~needle:"the header a call answers is a temporary"; + (* There is no [as-slice]: one word takes the view and what it is given says + what the view is. The refusal names [slice] and writes the call back out, + because a reader meeting it has no reason to know there was ever a second + name. *) + rejects_check "as-slice is not a name" + (vec ^ "[i32] (as-slice v))") ~needle:"there is no as-slice"; + rejects_check "as-slice suggests the call that replaces it" + (vec ^ "[i32] (as-slice v 1 3))") ~needle:"Write (slice v 1 3)"; + rejects_check "as-slice over an expression suggests a form that compiles" + "(defn mk [] (Vec i32) (vec-new i32)) \ + (defn f [] [i32] (as-slice (mk) 1 3))" + ~needle:"Write (slice v 1 3)"; + (* A program's own definition of the name still reaches its own. *) + accepts "a program may define as-slice" + "(defn as-slice [n i32] i32 n) (defn f [] i32 (as-slice 3))"; (* An array a call returned is a temporary the slice would outlive. It dangled silently on both backends before, at the three-argument spelling; (slice (mk)) is the spelling that would have made it @@ -2970,7 +3011,7 @@ let () = accepts "a global Vec is borrowed, mutated and cloned" "(defonce g (Vec u8)) \ (defn f [] () (set g (vec-new u8)) (push g 1) (set (at g 0) 2) \ - (println (len (as-slice g))) (let [c (clone g)] (free c)))"; + (println (len (slice g))) (let [c (clone g)] (free c)))"; rejects_check "try is milestone 6" "(defn f [] i32 (try 1))" ~needle:"milestone 6"; (* dotimes and defer are implemented, and a defer in a [let] is now one of diff --git a/test/test_valgrind.ml b/test/test_valgrind.ml index 8631e71..007c79c 100644 --- a/test/test_valgrind.ml +++ b/test/test_valgrind.ml @@ -316,7 +316,7 @@ let heap_oob = \ (let [v (vec-new i32)]\n\ \ (push v 1)\n\ \ (push v 2)\n\ - \ (let [s (as-slice v)] (print (at s 4000)) (println \"\"))\n\ + \ (let [s (slice v)] (print (at s 4000)) (println \"\"))\n\ \ (free v))\n\ \ 0)\n" @@ -334,7 +334,7 @@ let heap_uninit = \ (let [v (vec-new i32)]\n\ \ (push v 1)\n\ \ (push v 2)\n\ - \ (let [s (as-slice v)] (print (at s 3)) (println \"\"))\n\ + \ (let [s (slice v)] (print (at s 3)) (println \"\"))\n\ \ (free v))\n\ \ 0)\n" @@ -397,7 +397,7 @@ let arena_reuse = \ (with-allocator frame\n\ \ (let [w (vec-new i32)]\n\ \ (push w 5) (push w 6)\n\ - \ (let [t (as-slice w)] (print (at t 3)) (println \"\"))))\n\ + \ (let [t (slice w)] (print (at t 3)) (println \"\"))))\n\ \ 0)\n" let () = diff --git a/vendor/edn/provide.flan b/vendor/edn/provide.flan index 8a9b36c..f4d9170 100644 --- a/vendor/edn/provide.flan +++ b/vendor/edn/provide.flan @@ -61,7 +61,7 @@ (let [v (vec-new u8)] (append (addr v) (bytes-view a)) (append (addr v) (bytes-view b)) - (string (as-slice v)))) + (string (slice v)))) (defn joined3 [a string b string c string] string (joined a (joined b c))) @@ -74,7 +74,7 @@ (defn i64->string [n i64] string (let [v (vec-new u8)] (append-i64 (addr v) n) - (string (as-slice v)))) + (string (slice v)))) ;; The tokenizer answers byte offsets, because that is what a slice into the ;; buffer costs nothing to produce. A person reading a refusal wants a line and @@ -150,7 +150,7 @@ (let [t (expect c tok-string) b (vec-new u8 a)] (append (addr b) (.text t)) - (string (as-slice b)))) + (string (slice b)))) ;; Whether the next thing, past trivia, is this byte. Enough of a peek for ;; every loop below — "is the collection over" is the only lookahead a reader @@ -463,7 +463,7 @@ (return out)))) (let [sname (Form.Sym {.s name}) rname (Form.Sym {.s (joined "read-" name)}) - struct `(defstruct ~sname ~(Form.Vec {.xs (as-slice fields)})) + struct `(defstruct ~sname ~(Form.Vec {.xs (slice fields)})) reader `(defn ~rname [c (Ptr Cursor) a Allocator] ~sname (let [out (~sname {}) @@ -472,16 +472,16 @@ (while (ok? c) (let [k (next c)] (when (or (not (ok? c)) (= (.kind k) tok-map-close)) - ~@(as-slice missing) + ~@(slice missing) (return out)) (when (!= (.kind k) tok-keyword) (fail c err-unexpected-token (.pos k)) (return out)) - (cond ~@(as-slice clauses)))) + (cond ~@(slice clauses)))) out))] (push decls struct) (push decls reader) - (ok-derived sname (as-slice decls) `(~rname c a))))) + (ok-derived sname (slice decls) `(~rname c a))))) ;; ── Comparing and rendering a type form ───────────────────────────── @@ -572,7 +572,7 @@ out)) (defn ~fname [p string a Allocator] ~sname (let [b (slurp p a)] - (~bname (as-slice b) a))))))))) + (~bname (slice b) a))))))))) ;; A refusal, as a declaration. `compile-error` is an expression and a ;; top-level position takes a declaration, so it goes in the body of a function diff --git a/vendor/edn/read.flan b/vendor/edn/read.flan index 55c530d..cb973ff 100644 --- a/vendor/edn/read.flan +++ b/vendor/edn/read.flan @@ -68,7 +68,7 @@ (defn copy-text [s [u8]] string (let [b (vec-new u8)] (append (addr b) s) - (string (as-slice b)))) + (string (slice b)))) ;; ── Reading ───────────────────────────────────────────────────────── @@ -158,4 +158,4 @@ (defn read-file [path string] dyn (let [src (slurp path (heap-allocator))] (defer (free src)) - (read (as-slice src)))) + (read (slice src)))) diff --git a/vendor/json/json.flan b/vendor/json/json.flan index a186b08..bdb80c9 100644 --- a/vendor/json/json.flan +++ b/vendor/json/json.flan @@ -661,7 +661,7 @@ ;; free-all and under the heap it is a block nobody has freed. There is no ;; third owner. ;; -;; The builder is a (Vec u8) and the answer is (string (as-slice b)) taken ONCE +;; The builder is a (Vec u8) and the answer is (string (slice b)) taken ONCE ;; at the end. That ordering is load-bearing rather than stylistic: a push ;; after the slice has been taken can grow the Vec into a new block, and in an ;; arena the old block is still mapped, so the already-taken string would go on @@ -718,7 +718,7 @@ ;; because a dropped character is not worth a crash and the ;; tokenizer is the place that already said no. None (do))))))))) - (Some (string (as-slice b))))) + (Some (string (slice b))))) ;; ── Reading past a value ──────────────────────────────────────────── diff --git a/vendor/json/provide.flan b/vendor/json/provide.flan index 23a0dae..2312b5e 100644 --- a/vendor/json/provide.flan +++ b/vendor/json/provide.flan @@ -46,7 +46,7 @@ (let [v (vec-new u8)] (append (addr v) (bytes-view a)) (append (addr v) (bytes-view b)) - (string (as-slice v)))) + (string (slice v)))) (defn joined3 [a string b string c string] string (joined a (joined b c))) @@ -58,7 +58,7 @@ (defn i64->string [n i64] string (let [v (vec-new u8)] (append-i64 (addr v) n) - (string (as-slice v)))) + (string (slice v)))) ;; The tokenizer answers byte offsets. A person reading a refusal wants a line ;; and a column, so the newlines before the offset are counted here — once per @@ -324,7 +324,7 @@ (return out)))) (let [sname (Form.Sym {.s name}) rname (Form.Sym {.s (joined "read-" name)}) - struct `(defstruct ~sname ~(Form.Vec {.xs (as-slice fields)})) + struct `(defstruct ~sname ~(Form.Vec {.xs (slice fields)})) reader `(defn ~rname [c (Ptr Cursor) a Allocator] ~sname (let [out (~sname {}) @@ -333,14 +333,14 @@ (while (and (ok? c) (not (at-byte? c \}))) (let [k (expect c tok-string)] (expect c tok-colon) - (cond ~@(as-slice clauses)) + (cond ~@(slice clauses)) (comma c))) (let [close (expect c tok-object-close)] - ~@(as-slice missing)) + ~@(slice missing)) out))] (push decls struct) (push decls reader) - (ok-derived sname (as-slice decls) `(~rname c a))))) + (ok-derived sname (slice decls) `(~rname c a))))) ;; ── The small predicates the refusals are written against ─────────── @@ -375,7 +375,7 @@ (defn copy-of [s [u8]] string (let [b (vec-new u8)] (append (addr b) s) - (string (as-slice b)))) + (string (slice b)))) ;; ── Comparing and rendering a type form ───────────────────────────── @@ -453,7 +453,7 @@ out)) (defn ~fname [p string a Allocator] ~sname (let [b (slurp p a)] - (~bname (as-slice b) a))))))))) + (~bname (slice b) a))))))))) ;; A refusal, as a declaration. `compile-error` is an expression and a top-level ;; position takes a declaration, so it goes in the body of a function nothing diff --git a/web/index.html b/web/index.html index 995701d..778bf3e 100644 --- a/web/index.html +++ b/web/index.html @@ -911,7 +911,7 @@ type as an argument — (vec-new t), (map-new t i32), (let [ns [5 3 9 1] one (one-of 4.5)] (println (first-or (slice ns 0 4) -1)) - (println (at (as-slice one) 0)) + (println (at (slice one) 0)) (free one)))
3

From c3319e6268b5ec5f93847e5fec2bac338f51a586 Mon Sep 17 00:00:00 2001
From: Joseph Ferano 
Date: Mon, 21 Sep 2026 09:59:33 +0700
Subject: [PATCH 2/2] Review follow-ups: a leak is the program's, a wrong
 answer is not
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Three things the review found, and the refusal it was right about.

The returned-Vec refusal is gone. It called a leak a dangle: the storage a
returned Vec owns outlives the expression, so the view reads what it says it
reads, and what is lost is the owner. (len (mk)) and (at (mk) 0) lose the same
owner and compile, spec-memory.md already says an overwritten global Vec leaks
its first block, and under a region there is nothing to leak at all. "We're
purposely doing manual memory management for the static side, so whatever."
The array refusal stays exactly as it is — that one is a view into a frame
that is gone and answers bytes the frame has since reused. Wrong answers are
the compiler's business and leaks are the program's, and both docs now draw
that line, because the two forms look alike.

The -1 sentinel was reachable from user syntax: (slice v 0 -1) answered the
whole Vec on both backends while (slice a 0 -1) was refused as a negative
bound. The refusal now runs on the bounds the reader wrote, before the
implicit hi is built — the only order that works, since the sentinel is itself
a -1 and a check on the finished pair would refuse (slice v). The
backwards-pair check moved into the branch where both ends are written.

The bounds seam is closed toward index_expr, and the tiebreaker is not which
half is older. indexed and vec_at both take their index through it, so
(at a c) over a u32 compiled where (slice a c) did not: the fork was between
slice and at as much as between two targets. A bound is a subscript.

Also an x86 row for vec.flan, so the new arity is pinned on both backends in
CI rather than by hand, and the comment columns the sweep shifted left in
slurp, into, format and algorithms.
---
 FIX.org                       | 79 ++++++++++++++++++++++++++++----
 docs/BUILT.md                 |  8 ++++
 lib/check.ml                  | 84 ++++++++++++++++++++++-------------
 spec-memory.md                |  5 +++
 test/programs/algorithms.flan |  4 +-
 test/programs/format.flan     |  2 +-
 test/programs/into.flan       | 12 ++---
 test/programs/slurp.flan      | 10 ++---
 test/test_acceptance.ml       |  5 +++
 test/test_flan.ml             | 44 +++++++++++++-----
 10 files changed, 189 insertions(+), 64 deletions(-)

diff --git a/FIX.org b/FIX.org
index efd0249..c3d4e47 100644
--- a/FIX.org
+++ b/FIX.org
@@ -4782,14 +4782,44 @@ the target, nothing computed that was not computed before. The Vec had no
 two-argument spelling only because the name it had was not the name that grew
 the arities.
 
-*A Vec a call returned is refused, at every arity*, and for a different reason
-than the array. [(slice (mk))] over an array dangles: the view outlives a
-temporary the frame reuses. [(slice (make-vec))] does not dangle — the storage
-a returned Vec owns lives until its allocator's free-all or destroy. What is
-lost is the *owner*: the header is a temporary, so nothing can ever free that
-block, and the program has written a leak it cannot spell the fix for. The
-refusal says that rather than borrowing the array's sentence, and names the
-[let] that keeps the owner.
+*A Vec a call returned is accepted*, where an array a call returned is
+refused. This lane first refused it, the review pushed back, and the author
+ruled:
+
+#+begin_quote
+we're purposely doing manual memory management for the static side, so whatever
+#+end_quote
+
+[(slice (mk))] over an array dangles: the view outlives a temporary the frame
+reuses, and the dangle is the whole reason that refusal exists.
+[(slice (make-vec))] does not dangle — the storage a returned Vec owns lives
+until its allocator's free-all or destroy, so the view reads what it says it
+reads. What a returned Vec loses is the *owner*, and losing an owner is a
+leak, which this language has already ruled is defined behaviour:
+spec-memory.md on overwriting a global Vec says it "overwrites the first block
+and leaks it; there is no drop", and programs/strings.flan says "leaking is
+defined behaviour" out loud.
+
+So the refusal singled out one of three operations that lose the same owner.
+[(len (mk))] and [(at (mk) 0)] compile and leak the identical block, and
+refusing only the third would have been a rule about a spelling rather than
+about a hazard. It also broke code [as-slice] accepted, including the case
+where there is nothing to leak at all:
+[(with-allocator context/temp (println (len (slice (mk)))))] — the arena takes
+the block back whatever anyone does with the header. Dropped, and check.ml
+says why beside the array refusal it sits next to, so that the asymmetry reads
+as deliberate rather than as an oversight.
+
+*The array refusal stays, and the line between the two is the point.* They
+look alike and they are not. A view into a returned array points at bytes the
+frame has already handed to something else, so it answers a number that was
+never in the array — a wrong answer, silently, on both backends, with nothing
+to trap on. A view into a returned Vec answers exactly the elements it says it
+does; the cost is a block nobody can free. Wrong answers are the compiler's
+business and leaks are the program's, which is the whole of why one refusal is
+kept and the other is gone. BUILT.md and spec-memory.md both say it, because a
+reader meeting one of the two forms will assume the other behaves the same
+way.
 
 The refusal for the name itself is in [ordinary_call], after every table, so a
 program that defines an [as-slice] of its own still reaches its own. It reads
@@ -4840,6 +4870,39 @@ first one is sufficient on its own.
 What was built instead is the sentence, in the two places a reader meets the
 operation that breaks the view.
 
+The independent review sharpened the argument and reached the same place. The
+reserve witness kills the cheap per-push flag. The refined version — flag a
+push only when no reserve on *that* Vec came between — has to know which Vec a
+view was taken from and what happened to it in between, across calls and
+control flow, which is the static flow tracking repealed on 2026-09-18.
+Witness kills the cheap check; repeal kills the sound one.
+
+** Two forks closed on review, and one refusal dropped
+Found by the independent review of this branch and fixed here.
+
+*The -1 sentinel was reachable from user syntax.* [(slice v 0 -1)] answered
+the whole Vec and [(slice v 1 -1)] the tail, on both backends, while
+[(slice a 0 -1)] over an array was refused as a negative bound — the same
+builtin giving the same literal opposite meanings. The refusal now runs on the
+bounds the reader *wrote*, before the implicit hi is built, which is the only
+order that works: the sentinel is itself a -1, so a check on the finished pair
+would refuse [(slice v)] itself. [(slice v -1)] is a compile error now, in the
+same words an array gets. The backwards-pair check moved into the branch where
+both ends are bounds somebody wrote, so it no longer has to step around a
+value nobody wrote.
+
+*The bounds fork is closed toward [index_expr].* The array path expected an
+i32 outright and the Vec path used [index_expr], so with a u32 in hand
+[(slice v c)] compiled and [(slice a c)] did not. The tiebreaker is not which
+half is older but what every other subscript in the language does: [indexed]
+and [vec_at] both take their index through [index_expr], so [(at a c)]
+compiled where [(slice a c)] did not — the fork was between [slice] and [at]
+as much as between two targets. A bound is a subscript; it takes the subscript
+rule. Nothing is loosened that the bounds check does not still catch, and i64
+and u64 are still refused by name on both paths.
+
+*The returned-Vec refusal is dropped*, as above.
+
 ** Swept
 Every spelling in the repo: lib/ (check.ml, prelude.ml, render.ml, shim.ml),
 runtime/flan_rt.c comments, test/ (test_flan.ml, test_acceptance.ml,
diff --git a/docs/BUILT.md b/docs/BUILT.md
index 283d7c5..3c7a469 100644
--- a/docs/BUILT.md
+++ b/docs/BUILT.md
@@ -2441,6 +2441,14 @@ memory; writing through it writes to memory the program no longer owns. **The co
 not tell you.** That is `spec-memory.md`'s explicit Zig/Odin contract, chosen instead of a borrow checker, and it is
 one rule: take the view again after the `push`.
 
+One thing it is *not*: a dangle. `(slice (make-vec))` is legal, and the view it answers reads what it says it reads —
+the storage a returned `Vec` owns outlives the expression. What does not survive is the header, which was a
+temporary, and it was the only handle `free` could have taken, so that block is leaked unless the allocator reclaims
+it wholesale at `free-all` or destroy. That is the same leak `(len (mk))` and `(at (mk) 0)` already write, and the
+same one `spec-memory.md` names when a global `Vec` is overwritten: manual memory management, and the program's
+business. `(slice (mk))` over a fixed **array** is refused, and the difference is worth being exact about — that one
+is a view into a frame that is gone, so it answers whatever the frame was reused for. A wrong answer is not a leak.
+
 It is stated here, beside `push`, rather than at `slice`, because the `slice` is not the moment anything goes wrong —
 the `push` is. There used to be a second name, `as-slice`, for taking a view of a `Vec`, on the theory that a
 different word at that call site was the warning. It was not one. The value handed to `slice` already decides what
diff --git a/lib/check.ml b/lib/check.ml
index fb5acd0..a104795 100644
--- a/lib/check.ml
+++ b/lib/check.ml
@@ -6025,42 +6025,54 @@ and vec_at ctx loc (target : Tast.expr) (idx : Ast.expr list) =
 
    No slot and no length read at any arity: -1 is the runtime's "to the end",
    so the short forms pass a constant where an array passes its length, and
-   the target appears exactly once in all three. *)
+   the target appears exactly once in all three.
+
+   A Vec a *call* returned is accepted, where an array a call returned is
+   refused a screen down. The array is a dangle — the view outlives a
+   temporary the frame reuses — and this is not: the storage a returned Vec
+   owns lives to its allocator's free-all or destroy, so the view reads what
+   it says it reads. What a returned Vec loses is the owner, and losing the
+   owner is a leak, which this language has already decided is defined
+   behaviour (spec-memory.md on overwriting a global Vec: "overwrites the
+   first block and leaks it; there is no drop"). [(len (mk))] and
+   [(at (mk) 0)] lose exactly the same owner and are accepted; refusing the
+   third of those three would be a rule about one spelling rather than about
+   a hazard, and under a region allocator there is nothing to leak at all. *)
 and vec_slice ctx ~want loc (target : Tast.expr) elem (bounds : Ast.expr list) =
-  (* The mirror of the array rule one screen down, for a different reason. An
-     array a call returned is storage the frame reuses, so the view dangles;
-     a Vec a call returned owns storage that outlives the expression, and what
-     is lost is the owner — the header is a temporary, so nothing can ever
-     [free] it and the block lives to the allocator's free-all or destroy. A
-     [let] keeps the owner, so that is what the refusal names. *)
-  (match target.Tast.e with
-   | Tast.Call _ | Tast.CallPtr _ ->
-     fail loc
-       "this slices a Vec a call returned, and the header a call answers is a \
-        temporary — the view outlives it, and the storage it names can no \
-        longer be freed, because there is no Vec left to free it through. \
-        Bind it first: (let [v (…)] (slice v …))"
-   | _ -> ());
   let int k = mk loc index_ty (Tast.Int (k, Types.I32)) in
-  (* -1 is "to the end". A Vec's length is not static, so unlike an array
-     there is no constant to fold and the runtime reads the length word it is
-     carrying anyway. *)
+  (* Every bound the reader wrote goes through here, and the -1 below does
+     not: a negative literal is refused exactly as it is on an array, in the
+     same words, and that refusal has to happen on the bounds a person wrote
+     rather than on the pair that comes out of this — the sentinel *is* a -1,
+     and checking afterwards would refuse (slice v) itself. There is no static
+     length to check the other direction against. *)
+  let bound (b : Ast.expr) =
+    let v = index_expr ctx b in
+    (match literal v with
+     | Some k ->
+       static_index b.Ast.loc (Types.Vec elem) ~past_end:true "slice bound" k
+     | None -> ());
+    v
+  in
+  (* -1 is the runtime's "to the end". A Vec's length is not static, so unlike
+     an array there is no constant to fold and the runtime reads the length
+     word the header is carrying anyway. *)
   let to_end () = int (-1L) in
   let lo, hi =
     match bounds with
     | [] -> int 0L, to_end ()
-    | [ lo ] -> index_expr ctx lo, to_end ()
-    | [ lo; hi ] -> index_expr ctx lo, index_expr ctx hi
+    | [ lo ] -> bound lo, to_end ()
+    | [ lo; hi ] ->
+      let lo = bound lo and hi = bound hi in
+      (* The same refusal the other targets get, and it is asked only here,
+         where both ends are bounds somebody wrote. *)
+      (match literal lo, literal hi with
+       | Some a, Some b when a > b ->
+         fail loc "slice [%Ld %Ld) runs backwards — lo must not exceed hi" a b
+       | _ -> ());
+      lo, hi
     | _ -> assert false
   in
-  (* The same refusal the other targets get. There is no static length to
-     check a single bound against, but a literal pair that runs backwards is
-     wrong without one — and [hi] here may be the -1 sentinel, which is not a
-     bound anybody wrote. *)
-  (match literal lo, literal hi with
-   | Some a, Some b when b >= 0L && a > b ->
-     fail loc "slice [%Ld %Ld) runs backwards — lo must not exceed hi" a b
-   | _ -> ());
   let out = fresh_slot ctx (Types.Slice elem) in
   let fill =
     rt loc Types.Unit "flan_vec_as_slice"
@@ -7578,14 +7590,22 @@ and named_call ?(qualified = false) ctx ~want loc name args =
          | Types.Array (n, _) -> int n
          | _ -> mk loc index_ty (Tast.Prim (Tast.Len, [ src () ]))
        in
+       (* [index_expr] and not an [i32] expectation, which is what this used
+          to be. One builtin cannot answer two ways about the same bound, and
+          the question is settled by what every *other* subscript in the
+          language already does: [indexed] and [vec_at] both take their index
+          through here, so [(at a c)] over a [u32] compiles and [(slice a c)]
+          used to not. A bound is a subscript; it takes the subscript rule.
+          Nothing is loosened by it that a bounds check does not still catch —
+          [index_expr] admits an integer narrower than 32 bits and a u32,
+          whose out-of-range values truncate to a negative i32 the unsigned
+          comparison rejects, and refuses i64 and u64 by name. *)
        let lo_loc, lo, hi_loc, hi =
          match bounds with
          | [] -> loc, int 0L, loc, whole_len ()
-         | [ lo ] ->
-           lo.Ast.loc, check ctx ~want:index_ty lo, loc, whole_len ()
+         | [ lo ] -> lo.Ast.loc, index_expr ctx lo, loc, whole_len ()
          | [ lo; hi ] ->
-           lo.Ast.loc, check ctx ~want:index_ty lo,
-           hi.Ast.loc, check ctx ~want:index_ty hi
+           lo.Ast.loc, index_expr ctx lo, hi.Ast.loc, index_expr ctx hi
          | _ -> assert false
        in
        (* A bound may sit one past the end, so the length is checked against
diff --git a/spec-memory.md b/spec-memory.md
index 9ced367..358ad33 100644
--- a/spec-memory.md
+++ b/spec-memory.md
@@ -107,6 +107,11 @@ region" for the rule that stands in its place and for what it costs.
   and a view holds the old address and the old length. This is the whole of the
   contract: the view goes stale at the `push`, not at the `slice`, and it is
   the program's business to take the view again afterwards.
+- A view of a `Vec` a call returned is legal and does not dangle — the storage
+  outlives the expression — but the header was the only handle `free` could
+  have taken, so that block is leaked unless its allocator reclaims it
+  wholesale. Same leak as `(len (mk))`, same rule as overwriting a global
+  `Vec`: manual memory management, and the program's business.
 - **The first implementation follows Zig/Odin's explicit model, not Rust's
   borrow checker.** Dev builds carry a generation word on `Vec` and trap on use
   of a stale slice. `Ptr` is the explicit lower-level escape hatch and has the
diff --git a/test/programs/algorithms.flan b/test/programs/algorithms.flan
index d86f288..3135821 100644
--- a/test/programs/algorithms.flan
+++ b/test/programs/algorithms.flan
@@ -107,14 +107,14 @@
   ;; which an in-place byte sort could not, since a literal lives in .rodata.
   (let [f (split (bytes-view "pear,apple,Fig,apple,banana") \,)]
     (sort-bytes (slice f))
-    (show-fields (slice f))          ; Fig apple apple banana pear
+    (show-fields (slice f))             ; Fig apple apple banana pear
     (free f))
 
   ;; And the round trip the whole second tier is for: split, sort, join.
   (let [f (split (bytes-view "delta,alpha,charlie,bravo") \,)]
     (sort-bytes (slice f))
     (let [j (join (slice f) (bytes-view " < "))]
-      (println (string (slice j)))   ; alpha < bravo < charlie < delta
+      (println (string (slice j)))      ; alpha < bravo < charlie < delta
       (free j))
     (free f))
   0)
diff --git a/test/programs/format.flan b/test/programs/format.flan
index dfec99b..c042c9a 100644
--- a/test/programs/format.flan
+++ b/test/programs/format.flan
@@ -95,6 +95,6 @@
     (let [f (format-f64 0.0166667 4)]
       (append (addr b) (slice f))
       (free f))
-    (println (string (slice b)))     ; fps 59.9 / frame 0.0167
+    (println (string (slice b)))        ; fps 59.9 / frame 0.0167
     (free b))
   0)
diff --git a/test/programs/into.flan b/test/programs/into.flan
index 34aad46..136fbde 100644
--- a/test/programs/into.flan
+++ b/test/programs/into.flan
@@ -46,20 +46,20 @@
   ;; No transforms: a copy into the destination named in the form.
   (let [xs [7 8 9]
         v (into xs (vec-new i32))]
-    (show (slice v))                          ; 7 8 9
+    (show (slice v))                             ; 7 8 9
     (free v))
 
   ;; map then filter.
   (let [xs [1 2 3 4 5 6]
         v (into xs (vec-new i32) (map double) (filter even?))]
-    (show (slice v))                          ; 2 4 6 8 10 12
+    (show (slice v))                             ; 2 4 6 8 10 12
     (free v))
 
   ;; filter then map, over the same source: a different answer, because the
   ;; stages are in the order they were written.
   (let [xs [1 2 3 4 5 6]
         v (into xs (vec-new i32) (filter even?) (map double))]
-    (show (slice v))                          ; 4 8 12
+    (show (slice v))                             ; 4 8 12
     (free v))
 
   ;; One pass and no intermediate collection. The two chains above pulled
@@ -69,8 +69,8 @@
   ;; A name as the source is read, not moved: src is still alive here.
   (let [src (into [3 1 2] (vec-new i32))
         v (into src (vec-new i32) (map double))]
-    (show (slice v))                          ; 6 2 4
-    (show (slice src))                        ; 3 1 2
+    (show (slice v))                             ; 6 2 4
+    (show (slice src))                           ; 3 1 2
     (free v)
     (free src))
 
@@ -88,7 +88,7 @@
   ;; elements come out of it.
   (let [xs [1 2 3 4]
         v (into (source (slice xs 0 4)) (vec-new i32) (filter even?))]
-    (show (slice v))                          ; 2 4
+    (show (slice v))                             ; 2 4
     (free v))
   (print builds) (println "")                    ; 1
   0)
diff --git a/test/programs/slurp.flan b/test/programs/slurp.flan
index f07f1e2..7aa4022 100644
--- a/test/programs/slurp.flan
+++ b/test/programs/slurp.flan
@@ -29,7 +29,7 @@
   ;; ── The happy path ────────────────────────────────────────────────
   (let [v (slurp "programs/assets/a.txt")]
     (println (len v))                   ; 13
-    (print (string (slice v)))       ; hello from a
+    (print (string (slice v)))          ; hello from a
     (free v))
 
   ;; Byte-exact, the same as an embed: nothing here decodes anything.
@@ -52,7 +52,7 @@
        (invoke-restart 'use-value "programs/assets/b.bin"))]
     (let [v (slurp "programs/assets/does-not-exist")]
       (println (len v))                 ; 3
-      (println (string (slice v)))   ; BBB
+      (println (string (slice v)))      ; BBB
       (free v)))
   (println seen)                        ; 1
   (println (= last-reason file-missing)) ; true
@@ -65,7 +65,7 @@
   (barf "slurp-out.txt" (bytes-view "round trip\n"))
   (let [v (slurp "slurp-out.txt")]
     (println (len v))                   ; 11
-    (print (string (slice v)))       ; round trip
+    (print (string (slice v)))          ; round trip
     (free v))
 
   ;; barf's own failure signals the same condition with op = write. A directory
@@ -80,7 +80,7 @@
   (println seen)                        ; 1
   (println (= last-op file-op-write))   ; true
   (let [v (slurp "slurp-out.txt")]
-    (print (string (slice v)))       ; second
+    (print (string (slice v)))          ; second
     (free v))
 
   ;; ── retry: the file was not there, so the handler makes it ────────
@@ -96,7 +96,7 @@
        (barf "slurp-made.txt" (bytes-view "made by the handler\n"))
        (invoke-restart 'retry))]
     (let [v (slurp "slurp-made.txt")]
-      (print (string (slice v)))     ; made by the handler
+      (print (string (slice v)))        ; made by the handler
       (free v)))
   (println seen)                        ; 1
   (println (= last-reason file-missing)) ; true
diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml
index 56bbc35..2aa15d3 100644
--- a/test/test_acceptance.ml
+++ b/test/test_acceptance.ml
@@ -1350,6 +1350,11 @@ let () =
     outputs "vec" "programs/vec.flan" vec_out;
     outputs ~opt:"-O0" "vec, -O0" "programs/vec.flan" vec_out;
     outputs ~dev:true "vec, dev" "programs/vec.flan" vec_out;
+    (* The x86 row, added when [slice] took the Vec over: the three arities
+       are one checker path and the runtime call underneath is the one
+       [as-slice] made, so agreement is by construction — and a row that says
+       so is what turns "by construction" into something CI knows. *)
+    outputs ~x86:true "vec, --x86" "programs/vec.flan" vec_out;
     (* The prelude's second tier: the functions that return new storage, every
        one of which the prelude used to refuse by name for want of an
        allocator. The cases are the ones that separate a correct version from a
diff --git a/test/test_flan.ml b/test/test_flan.ml
index 5fbf903..949f098 100644
--- a/test/test_flan.ml
+++ b/test/test_flan.ml
@@ -1978,16 +1978,26 @@ let () =
     ~needle:"runs backwards";
   rejects_check "slice of a Vec of four"
     (vec ^ "[i32] (slice v 0 1 2))") ~needle:"given 4 arguments";
-  (* The mirror of the returned-array refusal, for a different loss. The
-     storage a returned Vec owns outlives the expression, so the view does not
-     dangle — what is gone is the owner, and with it any way to free the
-     block. *)
-  rejects_check "slice of a returned Vec"
-    "(defn mk [] (Vec i32) (vec-new i32)) (defn f [] [i32] (slice (mk)))"
-    ~needle:"the header a call answers is a temporary";
-  rejects_check "slice of a returned Vec, three arguments"
-    "(defn mk [] (Vec i32) (vec-new i32)) (defn f [] [i32] (slice (mk) 0 1))"
-    ~needle:"the header a call answers is a temporary";
+  (* A negative bound reads the same on a Vec as on an array, and it has to be
+     asked of what the reader wrote: the implicit hi the short forms pass *is*
+     a -1, so a check on the finished pair would refuse (slice v) itself. The
+     one-argument form is the case that proves it runs in the right place. *)
+  rejects_check "negative Vec tail"   (vec ^ "[i32] (slice v -1))")
+    ~needle:"is negative";
+  rejects_check "negative Vec lo"     (vec ^ "[i32] (slice v -1 2))")
+    ~needle:"is negative";
+  rejects_check "the sentinel is not a bound anybody may write"
+    (vec ^ "[i32] (slice v 0 -1))") ~needle:"is negative";
+  (* A Vec a call returned is *accepted*, where an array a call returned is
+     not. The array dangles; this does not — the storage outlives the
+     expression — and what it loses is the owner, which is a leak, which is
+     defined behaviour here. (len (mk)) and (at (mk) 0) lose the same owner
+     and compile, so refusing only the third would be a rule about a spelling
+     rather than about a hazard. *)
+  accepts "slice of a returned Vec"
+    "(defn mk [] (Vec i32) (vec-new i32)) (defn f [] [i32] (slice (mk)))";
+  accepts "slice of a returned Vec, three arguments"
+    "(defn mk [] (Vec i32) (vec-new i32)) (defn f [] [i32] (slice (mk) 0 1))";
   (* There is no [as-slice]: one word takes the view and what it is given says
      what the view is. The refusal names [slice] and writes the call back out,
      because a reader meeting it has no reason to know there was ever a second
@@ -2017,6 +2027,20 @@ let () =
     ~needle:"a returned array is a temporary";
   accepts "slice of an array literal"
     "(defn f [] [i32] (slice [7 8 9]))";
+  (* One builtin, one answer about a bound. A slice bound is a subscript and
+     goes through [index_expr] like every other one, so a u32 is admitted on
+     an array exactly as it always was on a Vec, and an i64 is refused by name
+     on both. The array path used to expect an i32 outright, which made
+     (at a c) compile and (slice a c) not. *)
+  accepts "a u32 bound over an array"
+    (arr ^ "(defn f [c u32] [i32] (slice a c))");
+  accepts "a u32 bound over a Vec"
+    "(defn f [v (Vec i32) c u32] [i32] (slice v c))";
+  rejects_check "an i64 bound over an array"
+    (arr ^ "(defn f [c i64] [i32] (slice a c))") ~needle:"an index is an i32";
+  rejects_check "an i64 bound over a Vec"
+    "(defn f [v (Vec i32) c i64] [i32] (slice v c))"
+    ~needle:"an index is an i32";
 
   (* A string indexes and slices, and neither is a place: it is a view of
      bytes the program does not own — a literal's are in constant storage —