The numeric casts open dyn boxes, coercing across kinds with one warning per site
This commit is contained in:
commit
6baed9a0ba
92
FIX.org
92
FIX.org
@ -1033,3 +1033,95 @@ inverting the condition to move the last operand into the else arm costs a
|
|||||||
[not] per operand and worse locs than it buys. What would fix it is check_if
|
[not] per operand and worse locs than it buys. What would fix it is check_if
|
||||||
preferring the arm that is not a compiler temp when it decides which one to
|
preferring the arm that is not a compiler temp when it decides which one to
|
||||||
blame — a change in check.ml, which this lane did not own.
|
blame — a change in check.ml, which this lane did not own.
|
||||||
|
|
||||||
|
* A numeric cast opens a dyn box, decided 2026-09-20
|
||||||
|
Every numeric cast — =(f64 x)=, =(i64 x)=, =(u32 x)=, =(f32 x)=, all of
|
||||||
|
them — takes a dyn operand now. Until this, the cast arm refused it with "f64
|
||||||
|
converts a number, found dyn", and the only thing in the language that opened
|
||||||
|
a box was a typed parameter, so a program wanting a number out of a dyn wrote
|
||||||
|
a one-line function whose parameter slot did the unboxing and called *that*.
|
||||||
|
A cast is the operator for "convert this to that"; it is the spelling that
|
||||||
|
should have worked.
|
||||||
|
|
||||||
|
Three cases, and the middle one is the author's call.
|
||||||
|
|
||||||
|
1. Same kind. The box holds what the cast asks for, so the cast is the unbox
|
||||||
|
and nothing else. A dyn box only ever holds an i64, an f64 or a bool among
|
||||||
|
the numbers, so =(f32 d)= on a float box unboxes to f64 and narrows, and
|
||||||
|
=(u32 d)= on an int box unboxes to i64 and narrows — each by the rule the
|
||||||
|
same cast already follows on a typed operand.
|
||||||
|
|
||||||
|
2. Cross kind — COERCE, with a warning. The author's words were "just coerce
|
||||||
|
it with a warning". =(f64 int-box)= is 7 -> 7.0 and =(i64 float-box)= is
|
||||||
|
2.5 -> 2, the truncation toward zero =(i64 2.5)= already does, range-check
|
||||||
|
and ArithError included. This overrides the tempting rule of matching the
|
||||||
|
parameter boundary, which traps on a kind mismatch: a cast is already a
|
||||||
|
conversion operator — =(f64 5)= converts a typed integer — so converting
|
||||||
|
across the box is the cast doing its job. The warning exists because the
|
||||||
|
box's kind was not what the program apparently expected, not because the
|
||||||
|
conversion is in doubt.
|
||||||
|
|
||||||
|
3. A box holding a non-number traps: text, nil, keyword, vec, map — and
|
||||||
|
*bool*, which is not a special case but the parameter boundary's existing
|
||||||
|
answer mirrored. flan_dyn_need_i64 refuses a dyn holding true at a typed
|
||||||
|
i64 parameter today, and =(i64 d)= refuses it for the same reason and in
|
||||||
|
the same voice.
|
||||||
|
|
||||||
|
** The warning is once per SITE
|
||||||
|
These casts sit in per-cell-per-frame loops — sand.flan runs at 120fps — so a
|
||||||
|
per-occurrence line is a flood and not a diagnostic. check.ml threads the
|
||||||
|
site's loc text into the runtime call and flan_dyn.c keeps a small table of
|
||||||
|
sites it has already spoken about, keyed on the loc's *bytes* rather than its
|
||||||
|
address: the two backends emit their own constants for it and neither promises
|
||||||
|
that two mentions of one site share a pointer. Sixty-four sites, and past that
|
||||||
|
it stops deduplicating rather than stops warning — the noisy failure, not the
|
||||||
|
silent one. The line is:
|
||||||
|
|
||||||
|
flan FILE:LINE:COL: (f64 x) found a dyn holding an int, and converted it to f64 — warned once for this site
|
||||||
|
|
||||||
|
Both builds warn, dev and release. No precedent was found making a diagnostic
|
||||||
|
of this kind dev-only: the allocation registry's notes are the one runtime
|
||||||
|
family a release build drops, and those are a *feature* being disabled, not a
|
||||||
|
warning being hushed. After the first hit this costs a tag compare and a
|
||||||
|
linear scan of a handful of entries, which is nothing.
|
||||||
|
|
||||||
|
** How it lowers, and why that shape
|
||||||
|
check.ml's [cast_dyn] builds a branch, not a call that converts:
|
||||||
|
|
||||||
|
(let ([s d])
|
||||||
|
(if (= (flan_dyn_cast_kind s "file:1:2" "u32" 0) 1)
|
||||||
|
(u32 (flan_dyn_need_f64 s))
|
||||||
|
(u32 (flan_dyn_need_i64 s))))
|
||||||
|
|
||||||
|
flan_dyn_cast_kind answers 1 for a float box and 0 for an int box, traps for
|
||||||
|
everything else, and warns when the answer disagrees with the target. Each arm
|
||||||
|
is then an ordinary [Cast] over an ordinary need — *the same node* a typed
|
||||||
|
operand of that type would have produced.
|
||||||
|
|
||||||
|
The alternative was a coercing runtime entry point answering the finished
|
||||||
|
number, and it was rejected because =(i64 2.5)= is not a bare fptosi in this
|
||||||
|
compiler: Emit.check_cast range-checks it and signals ArithError when the
|
||||||
|
value will not fit, and lib/x86.ml does the same. A C function returning an
|
||||||
|
int64_t would have had to grow its own second opinion about range and NaN, in
|
||||||
|
a second place, for two backends — a fork of exactly the kind "Arithmetic
|
||||||
|
semantics do not fork across the two spaces" forbids. With the branch there is
|
||||||
|
nothing to keep in step, and "x86 tracks LLVM -O0" holds by construction:
|
||||||
|
programs/dyn-cast.flan prints byte-identical output on both backends,
|
||||||
|
warnings and trap included.
|
||||||
|
|
||||||
|
The generic cast arm — =(t x)= inside a body with ={:where (numeric? $t)}= —
|
||||||
|
did NOT grow a dyn case and did not need one: the operand's type there is what
|
||||||
|
the bound admits, and numeric? does not admit dyn, so a dyn cannot reach that
|
||||||
|
arm. Pinned in test_flan.ml.
|
||||||
|
|
||||||
|
** What this repeals
|
||||||
|
test_flan.ml's row "a keyword with no expectation converts as dyn" pinned
|
||||||
|
=(i64 :space)= as a *check* error. It is a well-typed program now and a
|
||||||
|
run-time trap instead; the row became an [accepts] saying so. That is the
|
||||||
|
whole of the behaviour change outside the new feature.
|
||||||
|
|
||||||
|
** For the author: the shims in sand.flan can go
|
||||||
|
sand.flan defines =dyn->f64= and =dyn->u32=, one-line functions whose only
|
||||||
|
job is that their parameter slot unboxes. Every call site can now write the
|
||||||
|
cast directly — =(f64 d)=, =(u32 d)= — and the two defns deleted. Not done
|
||||||
|
here: sand.flan is the author's WIP and this lane did not touch it.
|
||||||
|
|||||||
72
lib/check.ml
72
lib/check.ml
@ -1674,6 +1674,69 @@ let unbox loc (want : Types.t) (e : Tast.expr) : Tast.expr =
|
|||||||
then "f64" else "i64"))
|
then "f64" else "i64"))
|
||||||
| _ -> no_dyn_yet loc ~into:false want ""
|
| _ -> no_dyn_yet loc ~into:false want ""
|
||||||
|
|
||||||
|
(* ── A numeric cast written on a dyn, FIX.org 2026-09-20 ───────────────
|
||||||
|
*
|
||||||
|
[(f64 d)] where [d] is dyn. Until this, the only place in the language
|
||||||
|
that opened a box was a typed parameter, which is why a program that
|
||||||
|
wanted a number out of a dyn had to define a one-line function whose
|
||||||
|
parameter slot did the unboxing and call *that*. A cast is already the
|
||||||
|
operator for "convert this to that", so it is the spelling that should
|
||||||
|
have worked, and now does.
|
||||||
|
|
||||||
|
What is built is a branch on the box's tag, not a call that converts:
|
||||||
|
|
||||||
|
(let ([s d])
|
||||||
|
(if (= (flan_dyn_cast_kind s "file:1:2" "u32" 0) 1)
|
||||||
|
(u32 (flan_dyn_need_f64 s))
|
||||||
|
(u32 (flan_dyn_need_i64 s))))
|
||||||
|
|
||||||
|
Each arm is an ordinary [Cast] over an ordinary [need], so the conversion
|
||||||
|
is *the same node* a typed operand of that type would have produced.
|
||||||
|
That is the point of this shape rather than a coercing runtime entry point
|
||||||
|
that answers the finished number: [(i64 2.5)] is not a bare [fptosi] in
|
||||||
|
this compiler — [Emit.check_cast] range-checks it first and signals
|
||||||
|
ArithError when the value will not fit, and the x86 backend does the same
|
||||||
|
— so a C function returning an [int64_t] would have had to grow its own
|
||||||
|
second opinion about range and NaN, in a second place, for two backends.
|
||||||
|
Here there is nothing to keep in step: [(i64 float-box)] *is* [(i64 x)]
|
||||||
|
with an unbox in front of it, which is exactly what the semantics say.
|
||||||
|
|
||||||
|
[need_f64] and [need_i64] are the trapping entry points, and neither can
|
||||||
|
trap here: each is reached only on the arm where the tag has already been
|
||||||
|
read as its own. The trap that can happen is the runtime's, for a box
|
||||||
|
holding a non-number, and [flan_dyn_cast_kind] owns that sentence — bool
|
||||||
|
included, which is what [flan_dyn_need_i64] already does with a bool at a
|
||||||
|
typed parameter. The cross-kind case does not trap: it converts and warns
|
||||||
|
once for the site, the author's call, recorded in FIX.org.
|
||||||
|
|
||||||
|
The slot exists because the value is read three times — once for the tag,
|
||||||
|
once on whichever arm runs — and the argument may be an arbitrary
|
||||||
|
expression. [unbox_option] above builds the same shape for the same
|
||||||
|
reason. *)
|
||||||
|
let cast_dyn ctx loc (target : Types.t) (got : Tast.expr) : Tast.expr =
|
||||||
|
let s = fresh_slot ctx Types.Dyn in
|
||||||
|
let sv = mk loc Types.Dyn (Tast.Local s) in
|
||||||
|
let want_float = match target with Types.Float _ -> 1 | _ -> 0 in
|
||||||
|
let kind =
|
||||||
|
rt loc (Types.Int Types.I32) "flan_dyn_cast_kind"
|
||||||
|
[ sv; here loc;
|
||||||
|
mk loc Types.String (Tast.Str (Types.to_string target));
|
||||||
|
mk loc (Types.Int Types.I32) (Tast.Int (Int64.of_int want_float, Types.I32)) ]
|
||||||
|
in
|
||||||
|
let is_float =
|
||||||
|
mk loc Types.Bool
|
||||||
|
(Tast.Prim (Tast.Eq,
|
||||||
|
[ kind;
|
||||||
|
mk loc (Types.Int Types.I32) (Tast.Int (1L, Types.I32)) ]))
|
||||||
|
in
|
||||||
|
let arm sym ty = widen loc target (rt loc ty sym [ sv ]) in
|
||||||
|
mk loc target
|
||||||
|
(Tast.Let ([ (s, got) ],
|
||||||
|
[ mk loc target
|
||||||
|
(Tast.If (is_float,
|
||||||
|
arm "flan_dyn_need_f64" dyn_f64,
|
||||||
|
arm "flan_dyn_need_i64" dyn_i64)) ]))
|
||||||
|
|
||||||
(* nil is written [nil] and nothing else produces it, so this is the whole of
|
(* nil is written [nil] and nothing else produces it, so this is the whole of
|
||||||
"the checker can see a nil reaching here" — a name, not a dataflow fact.
|
"the checker can see a nil reaching here" — a name, not a dataflow fact.
|
||||||
There is no propagation through a [let] or a call in this checker (see
|
There is no propagation through a [let] or a call in this checker (see
|
||||||
@ -6123,9 +6186,16 @@ and named_call ctx ~want loc name args =
|
|||||||
let a = check ctx (List.hd args) in
|
let a = check ctx (List.hd args) in
|
||||||
(match a.Tast.ty with
|
(match a.Tast.ty with
|
||||||
| Types.Enum _ -> ()
|
| Types.Enum _ -> ()
|
||||||
|
(* A dyn opens here — [cast_dyn], FIX.org 2026-09-20. Only on this arm:
|
||||||
|
the generic one above casts to a type *variable*, whose [where] clause
|
||||||
|
says the operand is numeric, and a dyn is not what a [numeric?] bound
|
||||||
|
admits. *)
|
||||||
|
| Types.Dyn -> ()
|
||||||
| t when Types.is_numeric t -> ()
|
| t when Types.is_numeric t -> ()
|
||||||
| t -> fail loc "%s converts a number, found %s" name (Types.to_string t));
|
| t -> fail loc "%s converts a number, found %s" name (Types.to_string t));
|
||||||
prim (Tast.Cast target) target [ a ]
|
(match a.Tast.ty with
|
||||||
|
| Types.Dyn -> cast_dyn ctx loc target a
|
||||||
|
| _ -> prim (Tast.Cast target) target [ a ])
|
||||||
|
|
||||||
(* ── ordinary calls ────────────────────────────────────────────── *)
|
(* ── ordinary calls ────────────────────────────────────────────── *)
|
||||||
(* A local or a parameter holding a function value, called by the name it is
|
(* A local or a parameter holding a function value, called by the name it is
|
||||||
|
|||||||
@ -3653,6 +3653,11 @@ declare void @flan_dyn_print(i64)
|
|||||||
declare i64 @flan_dyn_need_i64(i64)
|
declare i64 @flan_dyn_need_i64(i64)
|
||||||
declare double @flan_dyn_need_f64(i64)
|
declare double @flan_dyn_need_f64(i64)
|
||||||
declare i32 @flan_dyn_need_bool(i64)
|
declare i32 @flan_dyn_need_bool(i64)
|
||||||
|
; A numeric cast written on a dyn answers which numeric tag the box holds;
|
||||||
|
; check.ml's [cast_dyn] branches on it and each arm is an ordinary need plus
|
||||||
|
; the ordinary cast. The two slices are the site's location and the target's
|
||||||
|
; name, each crossing as ptr+len.
|
||||||
|
declare i32 @flan_dyn_cast_kind(i64, ptr, i64, ptr, i64, i32)
|
||||||
; nil <-> None at an (Option T) boundary, and (Some nil)'s run-time half —
|
; nil <-> None at an (Option T) boundary, and (Some nil)'s run-time half —
|
||||||
; M2 queue item 4, check.ml's [box_option]/[unbox_option] and the [Some]
|
; M2 queue item 4, check.ml's [box_option]/[unbox_option] and the [Some]
|
||||||
; builtin.
|
; builtin.
|
||||||
|
|||||||
@ -1139,6 +1139,92 @@ uint8_t flan_dyn_need_bool(flan_dyn v) {
|
|||||||
return (uint8_t)(dyn_payload(v) ? 1 : 0);
|
return (uint8_t)(dyn_payload(v) ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── A numeric cast opening a box, FIX.org 2026-09-20 ───────────────────
|
||||||
|
*
|
||||||
|
* [(f64 d)] on a dyn. The *conversion* is not done here: [check.ml] lowers
|
||||||
|
* such a cast to a branch on this function's answer, and each arm is the
|
||||||
|
* ordinary [flan_dyn_need_i64]/[flan_dyn_need_f64] followed by the cast the
|
||||||
|
* emitter already emits for a typed argument of that type. So this function
|
||||||
|
* decides one thing — which of the two numeric tags the box holds — and
|
||||||
|
* everything about the arithmetic (the fptosi range check, NaN, the
|
||||||
|
* narrowing rule) stays where it already was, identical in both backends and
|
||||||
|
* identical to the typed spelling of the same cast.
|
||||||
|
*
|
||||||
|
* Answers 1 for a float box, 0 for an int box. Every other tag traps, with
|
||||||
|
* the same [trap1] sentence the typed boundary's own refusals use — bool
|
||||||
|
* included, which mirrors [flan_dyn_need_i64] refusing a bool today rather
|
||||||
|
* than inventing a new rule for casts.
|
||||||
|
*
|
||||||
|
* [want_float] is what the *target* type is: 1 for f32/f64, 0 for the
|
||||||
|
* integer widths. When it disagrees with the box, the cast still happens —
|
||||||
|
* the author's call, "just coerce it with a warning" — and the warning below
|
||||||
|
* is the whole of what the disagreement costs. A cast is already a
|
||||||
|
* conversion operator, [(f64 5)] converts a typed integer, so converting
|
||||||
|
* across the box is the cast doing its job; the warning exists because the
|
||||||
|
* box's kind was not what the program apparently expected.
|
||||||
|
*
|
||||||
|
* Once per *site*, not per value. These casts sit in per-cell-per-frame
|
||||||
|
* loops — sand.flan runs at 120fps — and a per-occurrence line would be a
|
||||||
|
* flood rather than a diagnostic. The site is the [loc] text [check.ml]
|
||||||
|
* passes in, and the table below is keyed on its *bytes* rather than its
|
||||||
|
* address: the two backends emit their own constants for it and neither
|
||||||
|
* promises that two mentions of one site share one pointer.
|
||||||
|
*
|
||||||
|
* The table is fixed and small because it is only ever as large as the
|
||||||
|
* number of cross-kind cast sites a program has, which is a handful in the
|
||||||
|
* programs this was written for. A program with more than [SITE_MAX] of them
|
||||||
|
* stops deduplicating for the overflow — it still warns, every time, which
|
||||||
|
* is the noisy failure rather than the silent one. Not thread-safe, and
|
||||||
|
* deliberately: a duplicated or dropped line under a race is a diagnostic
|
||||||
|
* that came out twice, and the alternative is a lock on a path that runs per
|
||||||
|
* cast in a frame loop. */
|
||||||
|
|
||||||
|
#define SITE_MAX 64
|
||||||
|
|
||||||
|
static struct { const uint8_t *ptr; int64_t len; } warned_sites[SITE_MAX];
|
||||||
|
static int warned_count;
|
||||||
|
|
||||||
|
static int site_first_time(const uint8_t *loc, int64_t loc_len) {
|
||||||
|
for (int i = 0; i < warned_count; i++)
|
||||||
|
if (warned_sites[i].len == loc_len &&
|
||||||
|
memcmp(warned_sites[i].ptr, loc, (size_t)loc_len) == 0)
|
||||||
|
return 0;
|
||||||
|
if (warned_count < SITE_MAX) {
|
||||||
|
warned_sites[warned_count].ptr = loc;
|
||||||
|
warned_sites[warned_count].len = loc_len;
|
||||||
|
warned_count++;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len,
|
||||||
|
const uint8_t *target, int64_t target_len,
|
||||||
|
int32_t want_float) {
|
||||||
|
int32_t tag = flan_dyn_tag(v);
|
||||||
|
if (tag != FLAN_DYN_TAG_INT && tag != FLAN_DYN_TAG_FLOAT) {
|
||||||
|
/* [trap1] takes the operation as a C string and the target is a Flan
|
||||||
|
* slice, so it is copied out. Every cast name is two or three bytes; the
|
||||||
|
* clamp is for a caller this file cannot see. */
|
||||||
|
char name[8];
|
||||||
|
size_t n = (size_t)target_len < sizeof name - 1 ? (size_t)target_len
|
||||||
|
: sizeof name - 1;
|
||||||
|
memcpy(name, target, n);
|
||||||
|
name[n] = '\0';
|
||||||
|
trap1(TYPE_TRAP, name, "a number was wanted", v);
|
||||||
|
}
|
||||||
|
int32_t is_float = tag == FLAN_DYN_TAG_FLOAT ? 1 : 0;
|
||||||
|
if (is_float != (want_float ? 1 : 0) && site_first_time(loc, loc_len)) {
|
||||||
|
fflush(stdout);
|
||||||
|
fprintf(stderr,
|
||||||
|
"flan %.*s: (%.*s x) found a dyn holding %s, and converted it to "
|
||||||
|
"%.*s — warned once for this site\n",
|
||||||
|
(int)loc_len, (const char *)loc, (int)target_len,
|
||||||
|
(const char *)target, is_float ? "a float" : "an int",
|
||||||
|
(int)target_len, (const char *)target);
|
||||||
|
}
|
||||||
|
return is_float;
|
||||||
|
}
|
||||||
|
|
||||||
/* nil <-> None at an (Option T) boundary. Cannot trap — every dyn value
|
/* nil <-> None at an (Option T) boundary. Cannot trap — every dyn value
|
||||||
* answers this one way or the other. */
|
* answers this one way or the other. */
|
||||||
int32_t flan_dyn_is_nil(flan_dyn v) {
|
int32_t flan_dyn_is_nil(flan_dyn v) {
|
||||||
|
|||||||
@ -144,6 +144,21 @@ int64_t flan_dyn_need_i64(flan_dyn v);
|
|||||||
double flan_dyn_need_f64(flan_dyn v);
|
double flan_dyn_need_f64(flan_dyn v);
|
||||||
uint8_t flan_dyn_need_bool(flan_dyn v);
|
uint8_t flan_dyn_need_bool(flan_dyn v);
|
||||||
|
|
||||||
|
/* A numeric cast written on a dyn — [(f64 d)], [(u32 d)] — FIX.org
|
||||||
|
* 2026-09-20. Unlike the parameter boundary above this one coerces: it
|
||||||
|
* answers which numeric tag the box holds (1 float, 0 int) and the caller
|
||||||
|
* branches, so the conversion itself is the cast the compiler already emits
|
||||||
|
* for a typed operand of that tag. A box holding anything else traps, bool
|
||||||
|
* included, exactly as [flan_dyn_need_i64] refuses one.
|
||||||
|
*
|
||||||
|
* [want_float] says what the target type is, and a disagreement writes one
|
||||||
|
* line to stderr — once per [loc], not once per value, because these casts
|
||||||
|
* run inside frame loops. [loc] and [target] are Flan slices: pointer and
|
||||||
|
* length, not NUL-terminated. */
|
||||||
|
int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len,
|
||||||
|
const uint8_t *target, int64_t target_len,
|
||||||
|
int32_t want_float);
|
||||||
|
|
||||||
/* nil <-> None at an (Option T) boundary, and (Some nil)'s refusal — M2 item
|
/* nil <-> None at an (Option T) boundary, and (Some nil)'s refusal — M2 item
|
||||||
* 4. [flan_dyn_is_nil] is the tag test the boundary's runtime half needs and
|
* 4. [flan_dyn_is_nil] is the tag test the boundary's runtime half needs and
|
||||||
* does not want to build out of [flan_dyn_tag] and a comparison at every call
|
* does not want to build out of [flan_dyn_tag] and a comparison at every call
|
||||||
|
|||||||
@ -296,6 +296,51 @@ int32_t flan_dyn_need_bool(flan_dyn v) {
|
|||||||
return c->u.b;
|
return c->u.b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The cast boundary's tag question — see flan_dyn.h. The stub keeps its own
|
||||||
|
* trap vocabulary, as every function above it does; what it must agree with
|
||||||
|
* the real runtime about is the *answer*, 1 for a float box and 0 for an int
|
||||||
|
* one, because that is what the compiler branches on. The once-per-site
|
||||||
|
* table is the real runtime's word for word: a program built against the
|
||||||
|
* stub that warns twice for one line would be a difference in the
|
||||||
|
* diagnostic, which is the thing this pair exists to keep identical. */
|
||||||
|
|
||||||
|
#define STUB_SITE_MAX 64
|
||||||
|
|
||||||
|
static struct { const uint8_t *ptr; int64_t len; } stub_warned[STUB_SITE_MAX];
|
||||||
|
static int stub_warned_count;
|
||||||
|
|
||||||
|
int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len,
|
||||||
|
const uint8_t *target, int64_t target_len,
|
||||||
|
int32_t want_float) {
|
||||||
|
cell *c = as(v);
|
||||||
|
if (c->tag != T_I64 && c->tag != T_F64)
|
||||||
|
dyn_trap("DynExpectedNumber",
|
||||||
|
"a numeric cast was written on this value and it is not a number");
|
||||||
|
int32_t is_float = c->tag == T_F64 ? 1 : 0;
|
||||||
|
if (is_float != (want_float ? 1 : 0)) {
|
||||||
|
int first = 1;
|
||||||
|
for (int i = 0; i < stub_warned_count; i++)
|
||||||
|
if (stub_warned[i].len == loc_len &&
|
||||||
|
memcmp(stub_warned[i].ptr, loc, (size_t)loc_len) == 0)
|
||||||
|
first = 0;
|
||||||
|
if (first) {
|
||||||
|
if (stub_warned_count < STUB_SITE_MAX) {
|
||||||
|
stub_warned[stub_warned_count].ptr = loc;
|
||||||
|
stub_warned[stub_warned_count].len = loc_len;
|
||||||
|
stub_warned_count++;
|
||||||
|
}
|
||||||
|
fflush(stdout);
|
||||||
|
fprintf(stderr,
|
||||||
|
"flan %.*s: (%.*s x) found a dyn holding %s, and converted it "
|
||||||
|
"to %.*s — warned once for this site\n",
|
||||||
|
(int)loc_len, (const char *)loc, (int)target_len,
|
||||||
|
(const char *)target, is_float ? "a float" : "an int",
|
||||||
|
(int)target_len, (const char *)target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return is_float;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Roots ─────────────────────────────────────────────────────────────
|
/* ── Roots ─────────────────────────────────────────────────────────────
|
||||||
*
|
*
|
||||||
* Recorded and otherwise ignored. The shadow stack is kept, and its depth
|
* Recorded and otherwise ignored. The shadow stack is kept, and its depth
|
||||||
|
|||||||
72
test/programs/dyn-cast.flan
Normal file
72
test/programs/dyn-cast.flan
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
;;;; A numeric cast opens a dyn box — FIX.org 2026-09-20.
|
||||||
|
;;;;
|
||||||
|
;;;; Before this, a typed parameter was the only thing in the language that
|
||||||
|
;;;; could open a box, so a program wanting a number out of a dyn wrote a
|
||||||
|
;;;; one-line function whose parameter slot did the unboxing and called that.
|
||||||
|
;;;; A cast is the operator for "convert this to that", so it is the spelling
|
||||||
|
;;;; that should have worked; here it does.
|
||||||
|
;;;;
|
||||||
|
;;;; Three behaviours, in the order main runs them:
|
||||||
|
;;;;
|
||||||
|
;;;; 1. Same kind. The box holds what the cast asks for, so the cast is the
|
||||||
|
;;;; unbox and nothing else is written or warned.
|
||||||
|
;;;; 2. Cross kind. The box holds the other number. The cast still converts —
|
||||||
|
;;;; exactly as the same cast converts a typed operand of that type — and
|
||||||
|
;;;; one line goes to stderr naming the site. The two cross-kind casts here
|
||||||
|
;;;; sit inside a dotimes that runs them eight times each, and stderr gets
|
||||||
|
;;;; two lines, because the warning is per *site*: these casts live in frame
|
||||||
|
;;;; loops and a per-value line would be a flood rather than a diagnostic.
|
||||||
|
;;;; 3. A box holding something that is not a number. That still traps, the way
|
||||||
|
;;;; a typed parameter traps on it today, and the runtime owns the sentence.
|
||||||
|
;;;; It is the last thing main does, so everything above it has printed.
|
||||||
|
|
||||||
|
;; The box. Unannotated parameters are dyn, so this is the boxing site and the
|
||||||
|
;; call below is where a typed literal crosses in.
|
||||||
|
(defn as-dyn [x] dyn x)
|
||||||
|
|
||||||
|
(defn main [] ()
|
||||||
|
(let [i (as-dyn 7)
|
||||||
|
f (as-dyn 2.5)]
|
||||||
|
;; ── 1. Same kind: the cast is the unbox ──────────────────────────
|
||||||
|
;; An int box to every integer width, and a float box to both floats.
|
||||||
|
;; None of these warns: the box holds what was asked for.
|
||||||
|
(print (i64 i))
|
||||||
|
(print "\n")
|
||||||
|
(print (i64 (i32 i)))
|
||||||
|
(print "\n")
|
||||||
|
(print (i64 (u32 i)))
|
||||||
|
(print "\n")
|
||||||
|
(print (i64 (u8 i)))
|
||||||
|
(print "\n")
|
||||||
|
(print (f64 f))
|
||||||
|
(print "\n")
|
||||||
|
(print (f64 (f32 f)))
|
||||||
|
(print "\n")
|
||||||
|
|
||||||
|
;; ── 2. Cross kind: it converts, and warns once for the site ──────
|
||||||
|
;; (f64 int-box) is 7 -> 7.0, the same widening (f64 7) does on a typed
|
||||||
|
;; operand. (i64 float-box) is 2.5 -> 2, the same truncation toward zero
|
||||||
|
;; (i64 2.5) does — range-checked by the emitter's own check_cast, because
|
||||||
|
;; the arm really is that cast with an unbox in front of it.
|
||||||
|
;;
|
||||||
|
;; Eight turns of the loop, two sites, two lines on stderr.
|
||||||
|
(dotimes [n 8]
|
||||||
|
(print (f64 i))
|
||||||
|
(print "\n")
|
||||||
|
(print (i64 f))
|
||||||
|
(print "\n"))
|
||||||
|
|
||||||
|
;; A third site, and the third warning. An int box straight to f32 is one
|
||||||
|
;; sitofp from i64, which is what (f32 x) on a typed i64 is. Written down
|
||||||
|
;; because the tempting "fix" — go through f64 and then narrow — rounds
|
||||||
|
;; twice and is a different number for values an i64 can hold and an f64
|
||||||
|
;; cannot round to an f32 the same way.
|
||||||
|
(print (f64 (f32 i)))
|
||||||
|
(print "\n"))
|
||||||
|
|
||||||
|
;; ── 3. And the box that holds no number ──────────────────────────────
|
||||||
|
;; A bool, which is what the typed parameter boundary already refuses: a dyn
|
||||||
|
;; holding true does not satisfy an i64 there and does not satisfy (i64 x)
|
||||||
|
;; here. The runtime owns the message.
|
||||||
|
(print (i64 (as-dyn true)))
|
||||||
|
(print "\n"))
|
||||||
@ -3730,6 +3730,83 @@ level "1"
|
|||||||
some_nil ~opt:"-O0" ();
|
some_nil ~opt:"-O0" ();
|
||||||
some_nil ~x86:true ();
|
some_nil ~x86:true ();
|
||||||
|
|
||||||
|
(* A numeric cast opening a dyn box — FIX.org 2026-09-20.
|
||||||
|
programs/dyn-cast.flan is one program because the three behaviours are
|
||||||
|
one story told in order: the same-kind casts print, the cross-kind ones
|
||||||
|
print and warn, and the non-numeric box ends the process. The trap is
|
||||||
|
last for the reason [dyn_boundary]'s is — one program, one ending.
|
||||||
|
|
||||||
|
Three things are asserted, and the third is the one that needed the
|
||||||
|
work. First, the numbers: the same-kind casts are pure unboxes, and the
|
||||||
|
cross-kind ones convert the way the same cast converts a typed operand,
|
||||||
|
so (f64 int-box) is 7 and (i64 float-box) is 2 — the truncation toward
|
||||||
|
zero (i64 2.5) already does, not a rounding this feature invented.
|
||||||
|
|
||||||
|
Second, the trap. A bool box is refused by [flan_dyn_cast_kind], which
|
||||||
|
is the decision to mirror the typed parameter boundary rather than to
|
||||||
|
invent a rule: a dyn holding true does not satisfy an i64 parameter
|
||||||
|
today either.
|
||||||
|
|
||||||
|
Third, ONCE PER SITE. The two cross-kind casts in the middle sit inside
|
||||||
|
a [dotimes] that runs eight times, so a per-value warning would put
|
||||||
|
sixteen lines on stderr; what is counted here is three — one for each
|
||||||
|
of the program's three cross-kind cast *sites*, the third being the
|
||||||
|
(f32 int-box) after the loop. That count is the whole reason the
|
||||||
|
runtime carries a table of sites at all: these casts live in frame
|
||||||
|
loops, sand.flan's at 120fps, and a flood is not a diagnostic.
|
||||||
|
|
||||||
|
[run] merges stderr into stdout, so the warnings and the numbers come
|
||||||
|
back in one string and the count is a count over it. *)
|
||||||
|
let dyn_cast_out = "7\n7\n7\n7\n2.5\n2.5\n" in
|
||||||
|
let dyn_cast ?opt ?x86 () =
|
||||||
|
let exe = compile ?opt ?x86 "programs/dyn-cast.flan" in
|
||||||
|
let code, text = run exe None in
|
||||||
|
let name =
|
||||||
|
"dyn: a numeric cast opens the box, and warns once per site"
|
||||||
|
^ (match opt with Some o -> ", " ^ o | None -> "")
|
||||||
|
^ (match x86 with Some true -> ", --x86" | _ -> "")
|
||||||
|
in
|
||||||
|
(* Occurrences of the warning's stable half. Counted rather than
|
||||||
|
matched, because what is being pinned is a number and not a
|
||||||
|
sentence. *)
|
||||||
|
let needle = "warned once for this site" in
|
||||||
|
let warnings =
|
||||||
|
let n = String.length needle and m = String.length text in
|
||||||
|
let c = ref 0 in
|
||||||
|
for i = 0 to m - n do
|
||||||
|
if String.sub text i n = needle then incr c
|
||||||
|
done;
|
||||||
|
!c
|
||||||
|
in
|
||||||
|
if code <> 134
|
||||||
|
|| not (contains text dyn_cast_out)
|
||||||
|
(* The two cross-kind conversions, by their values: 7 -> 7.0 and
|
||||||
|
2.5 -> 2, eight times each. *)
|
||||||
|
|| not (contains text "7\n2\n7\n2\n")
|
||||||
|
|| warnings <> 3
|
||||||
|
|| not (contains text "found a dyn holding an int, and converted it to f64")
|
||||||
|
|| not (contains text "found a dyn holding a float, and converted it to i64")
|
||||||
|
|| not (contains text "found a dyn holding an int, and converted it to f32")
|
||||||
|
(* The non-numeric box, in the runtime's own words. *)
|
||||||
|
|| not (contains text "bool, and a number was wanted")
|
||||||
|
then begin
|
||||||
|
incr failures;
|
||||||
|
Printf.printf
|
||||||
|
"FAIL %s\n got: %S (exit %d, %d warnings)\n wanted: %S, \
|
||||||
|
three warnings, then a trap (exit 134)\n"
|
||||||
|
name text code warnings dyn_cast_out
|
||||||
|
end;
|
||||||
|
(try Sys.remove exe with Sys_error _ -> ())
|
||||||
|
in
|
||||||
|
dyn_cast ();
|
||||||
|
dyn_cast ~opt:"-O0" ();
|
||||||
|
(* The dev daemon's default backend. The parity rule (FIX.org, "x86 tracks
|
||||||
|
LLVM -O0") is what this row is: the same numbers, the same three
|
||||||
|
warnings and the same trap, which is only true because the conversion
|
||||||
|
on each arm is the [Cast] node both backends already lowered rather
|
||||||
|
than arithmetic this feature wrote twice. *)
|
||||||
|
dyn_cast ~x86:true ();
|
||||||
|
|
||||||
(* ── Typed containers into dyn as views, M2 item 3 ────────────────
|
(* ── Typed containers into dyn as views, M2 item 3 ────────────────
|
||||||
programs/dyn-view.flan takes its mode from argv, the way bounds.flan
|
programs/dyn-view.flan takes its mode from argv, the way bounds.flan
|
||||||
does, because a survey and a trap cannot share a process: mode 0 is
|
does, because a survey and a trap cannot share a process: mode 0 is
|
||||||
|
|||||||
@ -1429,6 +1429,36 @@ let () =
|
|||||||
(defn main [] i32\n\
|
(defn main [] i32\n\
|
||||||
\ (match (unbox-opt (box-it 42)) (Some x) (if (= x 42) 0 1) None 1))";
|
\ (match (unbox-opt (box-it 42)) (Some x) (if (= x 42) 0 1) None 1))";
|
||||||
|
|
||||||
|
(* A numeric cast opens a dyn box — FIX.org 2026-09-20. The checker's half
|
||||||
|
is small: every cast name the language has admits a dyn operand now, and
|
||||||
|
that is what these rows are. What the cast then *does* is a run-time
|
||||||
|
question and lives in test_acceptance.ml's dyn-cast rows — the same split
|
||||||
|
as the typed boundary, whose refusal is here and whose trap is there. *)
|
||||||
|
accepts "every numeric cast takes a dyn"
|
||||||
|
"(defn d [] dyn 7)\n\
|
||||||
|
(defn main [] i32\n\
|
||||||
|
\ (do (i64 (d)) (i32 (d)) (i16 (d)) (i8 (d))\n\
|
||||||
|
\ (u64 (d)) (u32 (d)) (u16 (d)) (u8 (d))\n\
|
||||||
|
\ (f64 (d)) (f32 (d))\n\
|
||||||
|
\ 0))";
|
||||||
|
(* And a cast the checker could already see was wrong is wrong for the same
|
||||||
|
reason it was: dyn is admitted by name, not by the numeric test going
|
||||||
|
soft. A string is not a number and never reaches a runtime tag. *)
|
||||||
|
rejects_check "a cast still refuses a non-numeric typed operand"
|
||||||
|
"(defn main [] i32 (i64 \"hi\"))"
|
||||||
|
~needle:"converts a number";
|
||||||
|
(* The *generic* cast — [($t x)] in a body whose signature says
|
||||||
|
[{:where (numeric? $t)}] — is a separate arm in the checker and did not
|
||||||
|
grow a dyn case. It did not need one: the operand's type there is what
|
||||||
|
the bound admits, and [numeric?] does not admit dyn, so a dyn cannot
|
||||||
|
reach that arm to begin with. The refusal is the bound's, at the call
|
||||||
|
that would have instantiated it. *)
|
||||||
|
rejects_check "a generic cast's operand is still what its bound admits"
|
||||||
|
"(defn conv [x $t] $t {:where (numeric? $t)} (t x))\n\
|
||||||
|
(defn d [] dyn 7)\n\
|
||||||
|
(defn main [] i32 (i32 (conv (d))))"
|
||||||
|
~needle:"numeric?";
|
||||||
|
|
||||||
(* The map operations ride the words the typed map already owns: get, put,
|
(* The map operations ride the words the typed map already owns: get, put,
|
||||||
len, has-key? — one question, one word, on both sides. has-key? on a
|
len, has-key? — one question, one word, on both sides. has-key? on a
|
||||||
typed map still checks against its K. *)
|
typed map still checks against its K. *)
|
||||||
@ -1893,8 +1923,16 @@ let () =
|
|||||||
keyword-specific one. *)
|
keyword-specific one. *)
|
||||||
rejects_check "a keyword needs an enum"
|
rejects_check "a keyword needs an enum"
|
||||||
"(defn g [x i32] ()) (defn f [] () (g :space))" ~needle:"is expected here";
|
"(defn g [x i32] ()) (defn f [] () (g :space))" ~needle:"is expected here";
|
||||||
rejects_check "a keyword with no expectation converts as dyn"
|
(* This row used to be a rejection, and the sentence it wanted was the cast
|
||||||
"(defn f [] () (print (i64 :space)))" ~needle:"found dyn";
|
arm's "converts a number, found dyn". FIX.org 2026-09-20 took that
|
||||||
|
refusal away on purpose: a numeric cast opens a dyn box, so [(i64 d)]
|
||||||
|
compiles for every dyn [d] and the question of what the box holds moved
|
||||||
|
to run time. A keyword's box holds no number and traps there —
|
||||||
|
[flan_dyn_cast_kind]'s sentence, the same one a bool's box gets, pinned
|
||||||
|
in test_acceptance.ml's dyn-cast rows. What is left here is that the
|
||||||
|
program is now well-typed, which is the change. *)
|
||||||
|
accepts "a keyword is dyn, and a numeric cast on it is a run-time question"
|
||||||
|
"(defn f [] () (print (i64 :space)))";
|
||||||
rejects_check "a keyword that is not a member"
|
rejects_check "a keyword that is not a member"
|
||||||
"(defenum Key [space 32]) (defn g [k Key] ()) (defn f [] () (g :spcae))"
|
"(defenum Key [space 32]) (defn g [k Key] ()) (defn f [] () (g :spcae))"
|
||||||
~needle:"has no member :spcae";
|
~needle:"has no member :spcae";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user