diff --git a/lib/check.ml b/lib/check.ml index e350325..87f2457 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -1674,6 +1674,69 @@ let unbox loc (want : Types.t) (e : Tast.expr) : Tast.expr = then "f64" else "i64")) | _ -> 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 "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 @@ -6123,9 +6186,16 @@ and named_call ctx ~want loc name args = let a = check ctx (List.hd args) in (match a.Tast.ty with | 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 -> 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 ────────────────────────────────────────────── *) (* A local or a parameter holding a function value, called by the name it is diff --git a/lib/emit.ml b/lib/emit.ml index 3eeaecb..f6261c7 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -3653,6 +3653,11 @@ declare void @flan_dyn_print(i64) declare i64 @flan_dyn_need_i64(i64) declare double @flan_dyn_need_f64(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 — ; M2 queue item 4, check.ml's [box_option]/[unbox_option] and the [Some] ; builtin. diff --git a/runtime/flan_dyn.c b/runtime/flan_dyn.c index 9cfd605..8e13dd4 100644 --- a/runtime/flan_dyn.c +++ b/runtime/flan_dyn.c @@ -1139,6 +1139,92 @@ uint8_t flan_dyn_need_bool(flan_dyn v) { 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, tag_of(v), (int)target_len, + (const char *)target); + } + return is_float; +} + /* nil <-> None at an (Option T) boundary. Cannot trap — every dyn value * answers this one way or the other. */ int32_t flan_dyn_is_nil(flan_dyn v) { diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h index 6a82133..2f95b6b 100644 --- a/runtime/flan_dyn.h +++ b/runtime/flan_dyn.h @@ -144,6 +144,21 @@ int64_t flan_dyn_need_i64(flan_dyn v); double flan_dyn_need_f64(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 * 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 diff --git a/runtime/flan_dyn_stub.c b/runtime/flan_dyn_stub.c index 779f49f..b11cff6 100644 --- a/runtime/flan_dyn_stub.c +++ b/runtime/flan_dyn_stub.c @@ -296,6 +296,51 @@ int32_t flan_dyn_need_bool(flan_dyn v) { 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 ? "float" : "int", + (int)target_len, (const char *)target); + } + } + return is_float; +} + /* ── Roots ───────────────────────────────────────────────────────────── * * Recorded and otherwise ignored. The shadow stack is kept, and its depth