flan/lib/types.ml
Joseph Ferano 2572f0a537 An fn sees the locals it was written among, and Fn says so in its type
spec-memory.md's case 2, capture by value into a stack environment, and
the calling convention the author's rulings asked for.

    (Fn  [i32] i32)   captures; {code, env}; the common case
    (CFn [i32] i32)   the bare address; one word; cannot capture

A local of the enclosing function that an fn names is copied into a
struct the checker synthesises, held in a slot of that function's frame,
and the value carries its address; the lifted body reads the copies back
into named slots of its own, once, at entry.  So the name in the body
means what the local held at the instant the value was made --
fn-capture.flan changes the local through a pointer after the value
exists and the fn still answers with the old one.

Two types rather than a uniform environment parameter: "while it's dyn
first, static side should never have to pay the price for the existence
of the dyn side... if you fully opt out, for instance, using --no-gc
flag, then we should be operating under Odin/C semantics and never paying
any runtime costs."  The environment is declared by exactly the bodies an
(Fn ...) value can reach -- a lifted literal in an Fn position, every
handler clause, and the widening thunks -- and by nothing else.  An
ordinary defn emits the signature it always did; calc-me and fourteen
corpus programs were diffed to say so.

CFn, because the C carries information: a value with no environment is
the only kind that could ever cross to C, and under the --no-conditions
direction FIX.org records it becomes literally a C function pointer.  It
is not that today -- a declare cannot take a function type at all -- and
crossable's refusal says so where a reader would otherwise be misled.
Nobody needs CFn: Fn accepts everything, and the commonest reason to
reach for the narrow one is that a *named* function handed to an Fn pays
a hop through the widening thunk where a CFn is a direct call.

That thunk is one small function per distinct signature widened, which
reads the bare address back out of the environment and calls it.  The
cheaper trick -- the environment last, ignored by a body that never
declared it -- is legal under SysV and is a trap under wasm32's
call_indirect, which compares the signature at the call.  Every indirect
call is exactly typed now.

A handler clause captures the same way and is sound with nothing left
over: its frame is popped by the body that pushed it.  What is refused
there is a *store* into a captured name -- it is a copy, and writing to
it would leave the local as it was.

And the other half, which is what "non-escaping" means: a value carrying
an environment may be called, passed down and let-bound, and may not be
returned, stored, pointed at or pushed into a container.  A parameter of
type Fn is treated as one, which answers "passed to something that stores
it" with no interprocedural analysis -- the store is refused inside the
callee.  Everything of type CFn is clean for free, which is the second
thing having two types buys.  Every refusal names case 3, the environment
the collector owns.

Two pre-existing bugs fell out on the way.  A lifted fn asked for Fnval,
so `flan reload' on any function containing an fn literal died at llc
with an undefined cell; it takes Flanfn now, which is the choice a
handler clause always made.  And a redefinition module now carries its
own hidden copy of every thunk it names, which is the same bug shape
caught before it shipped.
2026-09-21 13:41:44 +07:00

318 lines
16 KiB
OCaml

(** Resolved types: what [Ast.texpr] means once names are looked up.
The AST's type expressions are surface syntax — [Tname "Ptr"] and
[Tapp ("Option", ...)] are just names there. Here they are the real thing,
and two types are the same type exactly when they are structurally equal.
Milestone 2 has no generics, so there is no unification and no substitution:
a type variable is parsed, carried, and rejected the moment a value would
have to have it. That rejection lives in [Check]; this module only names
the shape. *)
(* Machine integer types. Signedness and width are both part of the type, and
two of them are the same type only when both halves match. A value may move
to a type that cannot lose it — see [widens_to] at the bottom of this file,
FIX.org 2026-09-20 — and never the other way: narrowing is written or it
does not happen. *)
type ikind = I8 | I16 | I32 | I64 | U8 | U16 | U32 | U64
type fkind = F32 | F64
type t =
| Int of ikind
| Float of fkind
| Bool
| String
| Unit (* the zero-sized type, not C's void *)
| Never (* return, exit, error: no value at all *)
| Named of string (* a struct or data type, declared here *)
(* A C enum: an i32 at run time, but its own type, so a keyword at a call
site has something to resolve against and a plain integer does not fit. *)
| Enum of string
| Slice of t (* [T] ptr+len, non-owning *)
| Array of int64 * t (* [n T] inline, a value, copies *)
| Map of t * t (* (Map K V) *)
| Ptr of t (* (Ptr T) *)
(* [Allocator]: a builtin opaque type, the way [string] is a builtin
ptr+len. It is a [Types.t] case with no user-writable constructor, which
is what lets spec-memory.md's "procedure plus an opaque data pointer" be
expressed with none of milestone 5's function values — the procedure is a
C symbol the emitter names and no Flan type ever mentions it. At run time
it is a pointer to the runtime's [flan_allocator], never a copy of one:
the capability set and the epoch have to be shared by every container
made from it, and a copy would give each its own. *)
| Alloc
(* [(Vec T)]: ptr + len + cap + allocator, owning and move-only. One
type-erased runtime over (size, align) stands behind every instantiation,
so this is a container without generics — the concrete type is known only
at the call site, which is exactly where the two numbers are produced. *)
| Vec of t
| Option of t (* (Option T) *)
(* The two function types, and the difference between them is what a value
of each one *is* rather than what it may do.
[(Fn [T ...] R)] is a code address and the environment it is called
with: two words. It is the common case and keeps the short name, because
it is what almost every higher-order signature wants — a caller may pass
it a name, a non-capturing literal, or one that captured half the frame,
and the callee neither knows nor cares.
[(CFn [T ...] R)] is the bare address: one word, no environment, and
therefore nothing that can capture.
**The [C] is information, not decoration.** A value with no environment
is the only kind that could ever cross to C, and under the
[--no-conditions] direction FIX.org records — where a signature that
cannot transfer drops the channel too — one becomes literally a C
function pointer. The name points at what the type *is* and at where it
is going.
What it does **not** point at is a capability that exists now: a
[declare] cannot take a function type at all today, because a Flan
signature ends with the transfer channel and a C caller knows nothing
about one. Anyone reaching for [CFn] straight after writing a
[declare-c] is reaching too early, and [crossable] says so where they
will meet it.
The whole of the reason there are two: a uniform environment would tax
every function in every program for a feature most of them never use,
and the static side is not to pay for the dynamic side's existence. With
two types an ordinary [defn] keeps exactly the signature it always had.
**Nobody ever needs [CFn].** [Fn] accepts everything a [CFn] does, so
the narrow one is reached for on purpose, for one of four reasons:
handing a function to C (later, as above); a table of bare addresses;
forbidding capture at a boundary; and the one that is likeliest in
practice — a *named* function passed to an [Fn] parameter goes through
the widening thunk and pays an indirect hop per call, where a [CFn]
parameter is a direct call. [(map-in-place s double)] is the example.
One-way: a [CFn] value satisfies an [Fn] (paired with a null
environment), and an [Fn] does not satisfy a [CFn] — there is nowhere
for the environment to go. *)
| Fn of t list * t (* (Fn [T ...] R) *)
| CFn of t list * t (* (CFn [T ...] R) *)
| Var of string (* a type variable — milestone 5 *)
(* [dyn]: one machine word whose contents the runtime knows and this module
does not. It is a written type — [(defonce x dyn 5)] boxes the 5 — and it
is also what an unannotated [defn] parameter means, which is why it is a
case here and not a Named type the prelude declares: the checker has to
recognise it to choose the boxing and the dyn op lowering, and a name in a
table cannot be matched on.
Nothing about the representation is stated here on purpose. The word is
opaque to the compiler — runtime/flan_dyn.h owns which bits are a tag —
so that milestone 2 can change the encoding without touching Emit. *)
| Dyn
let signed = function
| I8 | I16 | I32 | I64 -> true
| U8 | U16 | U32 | U64 -> false
let bits = function
| I8 | U8 -> 8 | I16 | U16 -> 16 | I32 | U32 -> 32 | I64 | U64 -> 64
let bits_f = function F32 -> 32 | F64 -> 64
(* [int] and [float] are the two builtin aliases, and they are spelled here
rather than as prelude [defalias]es so that they are the machine type and
not a second name for it. The difference is visible at a cast: [Check]'s
[is_cast] asks these two functions whether a head names a primitive, and an
entry in the alias table is not consulted there — so a prelude alias would
give [(int x)] no reading while [(i32 x)] had one. Named here, every path
that already accepts [i32] accepts [int] without learning the word.
Only these two. The rest of the foreign spellings — [long], [double],
[uint], [str] — stay refusals that teach the Flan name; see
[Check.foreign_spelling] for why the line is drawn where it is.
The mapping is one-way on purpose: [ikind_name] and [fkind_name] below
still answer [i32] and [f32], so every message, every DWARF name and every
inspector line the user sees says the machine type, whichever spelling the
source used. *)
let ikind_of_name = function
| "i8" -> Some I8 | "i16" -> Some I16 | "i32" | "int" -> Some I32
| "i64" -> Some I64
| "u8" -> Some U8 | "u16" -> Some U16 | "u32" -> Some U32 | "u64" -> Some U64
| _ -> None
let fkind_of_name = function
| "f32" | "float" -> Some F32 | "f64" -> Some F64 | _ -> None
(* Every name the resolver accepts as a primitive type. The list exists so a
near-miss can be reported as the typo it is. [Unit] is on it because the
resolver still answers to that name -- [Cimport] builds [Tname "Unit"] for
C's void, and never goes through the parser -- but nobody writes it: unit
is spelled [()] in source, and [Parse.texpr] refuses the word.
[int] and [float] are on it for the same reason they are in the two
functions above: the places that ask this list — whether a [defonce]'s third
element is a type, whether [(vec-new int)] names an element type, whether a
[let] binding vector has an annotation wedged into it — must answer the
same for [int] as for [i32], or the alias is a type-position-only spelling
and the identity is a half one. *)
let primitive_names =
[ "i8"; "i16"; "i32"; "i64"; "u8"; "u16"; "u32"; "u64";
"f32"; "f64"; "bool"; "string"; "dyn"; "Unit"; "Never"; "Allocator";
"int"; "float" ]
let ikind_name k =
(if signed k then "i" else "u") ^ string_of_int (bits k)
let fkind_name = function F32 -> "f32" | F64 -> "f64"
(* Structural equality is the whole story for *identity*: no subtyping, no
variance, and nothing here bends to admit a conversion. Implicit widening
(below) is deliberately not expressed as a loosening of this function or of
[fits] — it is a separate predicate that every caller must pair with a
[Cast] on the value, so a node's type never lies about the bits it holds.
Written out rather than using [=] so that adding a case with a function or
a mutable field cannot silently break it. *)
let rec equal a b =
match a, b with
| Int x, Int y -> x = y
| Float x, Float y -> x = y
(* [Dyn] is equal to itself and to nothing else. Two dyn values may hold
different things at run time, which is the point of the type and is not
this function's question: this is identity of *static* types, and there is
one dyn type the way there is one string type. *)
| Bool, Bool | String, String | Unit, Unit | Never, Never | Dyn, Dyn -> true
| Named x, Named y | Enum x, Enum y -> String.equal x y
| Slice x, Slice y -> equal x y
| Array (n, x), Array (m, y) -> Int64.equal n m && equal x y
| Map (k, v), Map (k', v') -> equal k k' && equal v v'
| Ptr x, Ptr y -> equal x y
| Alloc, Alloc -> true
| Vec x, Vec y -> equal x y
| Option x, Option y -> equal x y
(* The two are *not* equal to each other, in either direction. One-way
coercion lives in [Check.expect], where it can build the value the
wider type needs; here there is only identity. *)
| Fn (ps, r), Fn (ps', r') | CFn (ps, r), CFn (ps', r') ->
List.length ps = List.length ps'
&& List.for_all2 equal ps ps'
&& equal r r'
| Var x, Var y -> String.equal x y
| _ -> false
let rec to_string = function
| Int k -> ikind_name k
| Float k -> fkind_name k
| Bool -> "bool"
| String -> "string"
| Unit -> "()"
| Never -> "Never"
| Named n | Enum n -> n
| Slice t -> "[" ^ to_string t ^ "]"
| Array (n, t) -> Printf.sprintf "[%Ld %s]" n (to_string t)
| Map (k, v) -> Printf.sprintf "(Map %s %s)" (to_string k) (to_string v)
| Ptr t -> "(Ptr " ^ to_string t ^ ")"
| Alloc -> "Allocator"
| Vec t -> "(Vec " ^ to_string t ^ ")"
| Option t -> "(Option " ^ to_string t ^ ")"
| Fn (ps, r) ->
Printf.sprintf "(Fn [%s] %s)"
(String.concat " " (List.map to_string ps)) (to_string r)
| CFn (ps, r) ->
Printf.sprintf "(CFn [%s] %s)"
(String.concat " " (List.map to_string ps)) (to_string r)
| Var n -> n
| Dyn -> "dyn"
let is_numeric = function Int _ | Float _ -> true | _ -> false
(* Every integer kind, signed and unsigned, at every width — and nothing
else. This is [integer?]'s question: the bound that admits a body written
with %, the bitwise operators or the shifts, and that keeps the same body
from ever being instantiated at a float, where those operations either do
not exist or mean something different. *)
let is_integer = function Int _ -> true | _ -> false
(* The key types the first Map implementation admits (spec-memory.md, "Maps —
first implementation"): integers, enums, strings, fixed arrays, and value
structs composed recursively from those. Equality and hashing for them are
compiler-provided structural operations, so this is the whole of what the
emitted hash and equality pair has to cover — there is no dispatch to design
and no type class anywhere.
A struct is [Named], and whether its fields qualify cannot be decided here:
this module has no field table. [Check] finishes the job by walking them,
which is also where it emits the pair. Everything this does say no to says
no for a reason that will not change with a milestone: a [Ptr] or a [Slice]
key would hash an address, and hashing an address is a different operation
from hashing what it points at. *)
let rec keyable = function
| Int _ | Enum _ | Bool | String -> true
| Float _ -> false (* NaN /= NaN, and 0.0 and -0.0 differ bytewise *)
| Array (_, t) -> keyable t
| Named _ -> true (* [Check] decides, by walking the fields *)
| _ -> false
(* Ordering is defined on machine types and on nothing else — structs and
slices have no built-in [<], because an unconstrained type supports only
what every type supports (plan.org, Types). A string has no ordering
either: there is no true answer to whether one string is less than another
until the language picks a collation, and byte order is not it. *)
let is_comparable = function Enum _ -> true | t -> is_numeric t
(* Equality admits one type ordering does not: a string, grown in by the M2
queue's item 5 — bytewise, by content and not by address, so two
separately built strings with the same bytes are equal. *)
let is_equatable = function String -> true | t -> is_comparable t
(* [Never] is the type of an expression that does not produce a value: return,
an early-returning `some`, exit. It fits anywhere, and that is the only
place anything resembling subtyping exists. *)
let fits ~expected ~actual =
match actual with Never -> true | _ -> equal expected actual
(* ── Implicit widening, FIX.org 2026-09-20 ────────────────────────────
Which numeric types a value may move to without the program saying so.
One rule decides every entry: the conversion is admitted exactly when no
value of the source type can come out the other side as a different number.
Narrowing is not on this list and never will be — [(u32 x)] is how an i64
becomes a u32, because that one can lose.
Read out of that rule:
- Same signedness, strictly wider: i8→i16→i32→i64, u8→u16→u32→u64.
- Unsigned into a strictly wider signed: u8→i16, u8/u16→i32, u8/u16/u32→i64.
Every u32 fits in an i64, so nothing is lost. The mirror never holds:
signed into unsigned drops the negatives, at any width.
- Equal width across signedness (i32→u32, u32→i32) is refused for the same
reason — one of the two halves of the range has nowhere to go.
- f32→f64.
- Integer into float only where the float's significand covers the integer
exactly: f64 has 53 bits, so i8/i16/i32/u8/u16/u32 reach it and i64/u64 do
not (2^53+1 is not an f64); f32 has 24, so only i8/i16/u8/u16 reach it.
Odin is looser here and lets any integer into any float. This is the
tighter rule on purpose: a program that wants the lossy one writes (f64 x)
and says so, and loosening later adds programs where tightening later
would break them.
Nothing else participates. [Bool] is not a number, an [Enum] is its own type
whose whole point is that a bare integer does not fit it, [Dyn] crosses by
boxing and unboxing rather than by this, and a container is invariant: a
[Vec i32] is not a [Vec i64] and a [[i32]] is not a [[i64]], because the
elements would each have to be rewritten and a slice does not own its
bytes. *)
let widens_to ~(from : t) ~(into : t) =
match from, into with
| Int a, Int b ->
if signed a = signed b then bits b > bits a
else (not (signed a)) && signed b && bits b > bits a
| Float F32, Float F64 -> true
| Int a, Float b -> bits a <= (match b with F64 -> 32 | F32 -> 16)
| _ -> false
(* The type a binary operator's two operands meet at: whichever of the pair the
other one widens into, and nothing otherwise. That is total and it is not a
real lattice — (i32, u32) has no answer here, and inventing i64 for it would
be picking a type neither operand was written at. Equal types answer
themselves, so a caller can use this without checking for that first. *)
let join a b =
if equal a b then Some a
else if widens_to ~from:a ~into:b then Some b
else if widens_to ~from:b ~into:a then Some a
else None