flan/test/test_session.ml
Joseph Ferano d9711f82bc A form from a package file means what the import made it mean
C-c C-c on settle inside sand-sim/sim.flan declared settle, but the running
program only ever knew it as sim/settle. The form spliced as a brand-new
unrelated name, the evaluation answered ok, and nothing changed. Sand's
simulation lives in a package, so the one thing worth tuning live was the one
thing that silently did nothing - and reported success while doing it.

Load now records what alias each package directory was imported under and what
names it owns, because a file on disk does not say what it is called from
outside; the importer chooses that. A session looks the editing file's
directory up in that table and qualifies the incoming forms through Load's own
qualify_decl, so a redefined settle lands on sim/settle and its call to
move-grain lands on sim/move-grain, by the same rule the import used. A name
the package does not own - the prelude's - is left alone.

Derived from the path rather than sent by the editor, which is where this
departs from CIDER's ns key: a Clojure namespace is declared in the file, but a
Flan alias is not written anywhere the editor can see it. One directory
imported under two aliases is refused with the reason instead of resolved to
either.
2026-09-10 22:50:50 +07:00

160 lines
7.9 KiB
OCaml

(* The session: the declarations a running process was built from, plus every
change accepted since (NEXT.md, the dev loop).
Two halves. First, what a session refuses — every case here is a change that
would compile, load, install, and then be wrong, because a cell is a bare
pointer and storage that already exists already has a shape. Second, that
refusing leaves the session usable, which is the failure people actually hit:
one typo must not poison every later evaluation. *)
open Flan
let failures = ref 0
let fail fmt = Printf.ksprintf (fun s -> incr failures; print_endline ("FAIL " ^ s)) fmt
let has hay needle =
let n = String.length needle and h = String.length hay in
let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in
go 0
(* Every rejection is asserted on its reason, not just on the failure: the
reason is the part that has to survive a refactor. *)
let refuses ?(file = "programs/reload.flan") name src reason =
let t, _ = Session.create ~file in
match Session.eval t src with
| _ -> fail "%s was accepted" name
| exception Loc.Error (_, msg) ->
if not (has msg reason) then
fail "%s\n said: %S\n wanted it to mention: %S" name msg reason
let () =
(* A cell carries no signature, so every call site compiled before the change
still passes the old arguments through it. *)
(* [outer] is called only from C, and [spare] is read by nothing, so the
checker has no complaint about either change and the session is the only
thing that can refuse them. A change something else in the program uses is
an ordinary type error first, which is a different and louder failure. *)
refuses "a changed parameter type"
"(defn outer [x i64] i64 (bump))"
"changes signature";
refuses "a changed return type"
"(defn outer [] i32 (i32 (bump)))"
"changes signature";
refuses "a changed arity"
"(defn outer [a i64 b i64] i64 (bump))"
"changes signature";
(* The storage exists and has a shape: reusing it reads at the wrong offsets,
and replacing it discards the state the reload exists to preserve. *)
refuses "a retyped global"
"(defvar spare i32)"
"changes type";
(* Values of the type are already in the running program's memory. *)
(* A defconst is folded into its call sites — into an array length, at worst,
which is decided before any type resolves — so its value lives in the
program's code and not only in its storage. *)
refuses "a changed defconst"
"(defconst folded i64 8)"
"changes value";
(* An enum member is erased to an i32 literal in the caller, so the same
applies. It is compared over declarations because Tast.program carries no
enums at all, for exactly that reason. *)
refuses "a changed enum member"
"(defenum Colour [red 0 green 2])"
"changes its members";
refuses ~file:"programs/values.flan" "a restructured struct"
"(defstruct P [x i32 y i32])"
"changes layout";
(* An ordinary redefinition, and what the session works out about it. *)
let t, _ = Session.create ~file:"programs/reload.flan" in
let c = Session.eval t "(defn bump [] i64 (set counter (+ counter 5)) counter)" in
if not c.Session.installs then fail "a redefined function had nothing to install";
if c.Session.fns <> [ "bump" ] then
fail "redefining bump reported %s" (String.concat " " c.Session.fns);
(* The prelude is in the checked program and in no accumulated AST, so a
session that derived [known] from declarations would call print-line
through a registry cell nobody ever publishes. *)
if not (has c.Session.ir "@\"flan.cell.print-line\" = external global ptr") then
fail "the prelude was treated as new";
if has c.Session.ir "flan_dev_cell" then
fail "a name the host has went through the registry";
(* A form that does not check must leave the session exactly as it was. This
is the one that decides whether a REPL survives a typo. *)
(match Session.eval t "(defn bump [] i64 nonsense)" with
| _ -> fail "an unresolvable name was accepted"
| exception Loc.Error _ -> ());
(match Session.eval t "(defn bump [] i64 (set counter (+ counter 6)) counter)" with
| c -> if c.Session.fns <> [ "bump" ] then fail "the session did not recover"
| exception Loc.Error (_, m) -> fail "the session was poisoned by a typo: %s" m);
(* A declaration the program already has, with no body and no new storage,
is accepted and has nothing to send. Building a module for it would report
success for a change that cannot have taken effect, and would cost the
program a reload it did not need. *)
(match Session.eval t "(defvar counter i64)" with
| c -> if c.Session.installs then fail "an empty change claimed to install"
| exception Loc.Error (_, m) -> fail "redeclaring a var unchanged: %s" m);
(* A new global carries its declared initial value, copied once when the
storage is allocated and never again — calloc alone would make it zero. *)
let c = Session.eval t "(defvar started i64 42) (defn read-started [] i64 started)" in
if not (has c.Session.ir "@\".init.") then
fail "a new global's initialiser was dropped";
if not c.Session.installs then fail "adding a global had nothing to install";
(* Names the process was never built with go through the registry instead of
binding to a symbol, and adding one is allowed where retyping one is not. *)
let c = Session.eval t "(defvar fresh i64) (defn use-fresh [] i64 (set fresh 3) fresh)" in
if not (List.mem "fresh" c.Session.names && List.mem "use-fresh" c.Session.fns) then
fail "adding a var and a function reported %s" (String.concat " " c.Session.names);
if not (has c.Session.ir "call ptr @flan_dev_global") then
fail "a new global did not go through the registry";
(* And once added, it is part of the session: a later form can use it. *)
(match Session.eval t "(defn use-fresh [] i64 (set fresh 4) fresh)" with
| _ -> ()
| exception Loc.Error (_, m) -> fail "a name added earlier was forgotten: %s" m);
(* A file with imports, re-evaluated whole — the C-c C-k case. The session
keeps the *expanded* declarations, so the package's names are replaced in
place rather than appended a second time and rejected as duplicates. *)
let t, _ = Session.create ~file:"../sand.flan" in
let src = In_channel.with_open_bin "../sand.flan" In_channel.input_all in
(match Session.eval t src with
| c ->
if not (List.mem "game-draw" c.Session.fns) then
fail "reloading sand.flan did not include its own functions"
| exception Loc.Error (_, m) ->
fail "reloading a file with imports failed: %s" m);
(* A form typed into a file that is *imported as a package* has to be
qualified the way the import qualified it, or it splices as a brand-new
unrelated name: the evaluation reports success and the running program
goes on calling the one it already had. The alias is chosen by the
importer and written nowhere in the file, so the path is the only thing
that can decide it — which is why it is derived here and not sent by the
editor. *)
let t, _ = Session.create ~file:"../sand.flan" in
(match
Session.eval ~origin:"../sand-sim/sim.flan" t
"(defn settle [row i32 col i32] Unit (do))"
with
| c ->
if c.Session.fns <> [ "sim/settle" ] then
fail "a form from a package file reported %s, wanted sim/settle"
(String.concat " " c.Session.fns)
| exception Loc.Error (_, m) -> fail "redefining sim/settle: %s" m);
(* And a file that is not a package keeps its names as written. *)
(match Session.eval ~origin:"../sand.flan" t "(defn game-draw [] Unit (do))" with
| c ->
if c.Session.fns <> [ "game-draw" ] then
fail "a form from the program's own file reported %s"
(String.concat " " c.Session.fns)
| exception Loc.Error (_, m) -> fail "redefining game-draw: %s" m);
if !failures = 0 then print_endline "session: all tests passed"
else begin
Printf.printf "\n%d failure(s)\n" !failures;
exit 1
end