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.
This commit is contained in:
Joseph Ferano 2026-09-10 22:50:50 +07:00
parent 52d3898116
commit d9711f82bc
4 changed files with 108 additions and 5 deletions

15
NEXT.md
View File

@ -573,6 +573,21 @@ Two things the session knows that no single evaluation could:
the program uses, which is exactly where the silent version lives. The the program uses, which is exactly where the silent version lives. The
fixtures carry an unused `defvar` and a C-called `defn` for that reason. fixtures carry an unused `defvar` and a C-called `defn` for that reason.
**A form typed into a file that is imported as a package is qualified the way
the import qualified it.** `settle` in `sand-sim/sim.flan` becomes `sim/settle`,
and its call to `move-grain` becomes `sim/move-grain` — through `Load`'s own
`qualify_decl`, so the rule cannot drift from the one used at import time.
Without this the form spliced as a brand-new unrelated name: the evaluation
answered `ok`, and the running program went on calling the `sim/settle` it
already had. Since sand's simulation lives in a package, the one thing worth
tuning live was the one thing that silently did nothing.
It is derived from the file's path and **not 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 chosen by whatever imported the directory and is
written nowhere the editor can see. One directory imported under two aliases is
refused with the reason rather than resolved to either.
The accumulated list is the **post-`Load`** one, so an evaluated `(import …)` The accumulated list is the **post-`Load`** one, so an evaluated `(import …)`
is spliced as its expansion. Otherwise re-evaluating a file that imports is spliced as its expansion. Otherwise re-evaluating a file that imports
something appends a second import, `Load` expands it again, and the something appends a second import, `Load` expands it again, and the

View File

@ -30,8 +30,16 @@ type t = {
decls : Ast.decl list; decls : Ast.decl list;
csrcs : string list; (* C sources compiled into the build *) csrcs : string list; (* C sources compiled into the build *)
lflags : string list; (* extra linker arguments *) lflags : string list; (* extra linker arguments *)
(* Which alias each package's directory was imported under, and the names it
owns. A file on disk does not say what it is called from outside the
*importer* chooses that so this is the only place the answer exists, and
a REPL editing a package's source needs it to know that [settle] typed in
sand-sim/sim.flan means [sim/settle] to the running program. *)
pkgs : pkg list;
} }
and pkg = { alias : string; dir : string; owns : string list }
let fail loc fmt = Printf.ksprintf (fun m -> raise (Loc.Error (loc, m))) fmt let fail loc fmt = Printf.ksprintf (fun m -> raise (Loc.Error (loc, m))) fmt
(* "vendor:raylib" -> the collection "vendor" and the subpath "raylib". A path (* "vendor:raylib" -> the collection "vendor" and the subpath "raylib". A path
@ -252,7 +260,8 @@ let import ~loc alias dir =
close_in ch; r close_in ch; r
end end
in in
{ decls; csrcs = entries dir ".c"; lflags } { decls; csrcs = entries dir ".c"; lflags;
pkgs = [ { alias; dir; owns = owned } ] }
(* ── The one entry point ───────────────────────────────────────────── *) (* ── The one entry point ───────────────────────────────────────────── *)
@ -265,7 +274,8 @@ let program ~file (decls : Ast.decl list) : t =
let p = import ~loc:d.Ast.dloc alias dir in let p = import ~loc:d.Ast.dloc alias dir in
{ decls = acc.decls @ p.decls; { decls = acc.decls @ p.decls;
csrcs = acc.csrcs @ p.csrcs; csrcs = acc.csrcs @ p.csrcs;
lflags = acc.lflags @ p.lflags } lflags = acc.lflags @ p.lflags;
pkgs = acc.pkgs @ p.pkgs }
| _ -> { acc with decls = acc.decls @ [ d ] }) | _ -> { acc with decls = acc.decls @ [ d ] })
{ decls = []; csrcs = []; lflags = [] } { decls = []; csrcs = []; lflags = []; pkgs = [] }
decls decls

View File

@ -33,6 +33,7 @@ type t = {
mutable decls : Ast.decl list; (* post-Load: flat, one namespace *) mutable decls : Ast.decl list; (* post-Load: flat, one namespace *)
mutable program : Tast.program; (* the last thing that checked *) mutable program : Tast.program; (* the last thing that checked *)
host : Tast.program; (* what the process was built from *) host : Tast.program; (* what the process was built from *)
pkgs : Load.pkg list; (* alias, directory, names owned *)
} }
let fail = Loc.fail let fail = Loc.fail
@ -59,7 +60,44 @@ let rec same_const (a : Tast.expr) (b : Tast.expr) =
let create ~file = let create ~file =
let l = Load.program ~file (Parse.program (Reader.read_file file)) in let l = Load.program ~file (Parse.program (Reader.read_file file)) in
let p = Check.program l.Load.decls in let p = Check.program l.Load.decls in
({ file; decls = l.Load.decls; program = p; host = p }, l) ({ file; decls = l.Load.decls; program = p; host = p; pkgs = l.Load.pkgs }, l)
(* Which package a file being edited belongs to, if any.
A form typed into sand-sim/sim.flan declares [settle], but the running
program only ever knew it as [sim/settle]: the alias is chosen by whatever
imported the directory, and is written nowhere in the file itself. Without
this the form splices as a brand-new unrelated name, the evaluation reports
success, and nothing changes the exact failure this whole design is meant
to make impossible.
Derived from the path rather than sent by the editor for that same reason:
the editor cannot know an alias the file does not mention. *)
let package_of t origin =
match origin with
| "" -> None
| origin ->
let dir =
try Filename.dirname (Unix.realpath origin)
with Unix.Unix_error _ -> Filename.dirname origin
in
let same p =
let d =
try Unix.realpath p.Load.dir with Unix.Unix_error _ -> p.Load.dir
in
String.equal d dir
in
(match List.filter same t.pkgs with
| [] -> None
| [ p ] -> Some p
(* One directory under two aliases: both are live in the program and a
form cannot mean both. Say so rather than picking one. *)
| ps ->
Loc.fail Loc.unknown
"%s is imported under more than one alias (%s); a form here would \
have to mean all of them"
dir
(String.concat ", " (List.map (fun p -> p.Load.alias) ps)))
(* A name the running process exports. Everything else is looked up by name at (* A name the running process exports. Everything else is looked up by name at
install time see [Emit.redefinition]'s [known]. *) install time see [Emit.redefinition]'s [known]. *)
@ -207,7 +245,22 @@ let eval ?(origin = "<eval>") t src : change =
why the accumulated list is the post-Load one: re-evaluating a file that why the accumulated list is the post-Load one: re-evaluating a file that
imports something would otherwise append a second copy of the import and imports something would otherwise append a second copy of the import and
the duplicate-name pass would reject it. *) the duplicate-name pass would reject it. *)
let incoming = (Load.program ~file:t.file (Parse.program forms)).Load.decls in let incoming =
let ds = (Load.program ~file:t.file (Parse.program forms)).Load.decls in
match package_of t origin with
| None -> ds
| Some p ->
(* Qualified exactly as the import qualified them, so a redefined
[settle] lands on [sim/settle] and its call to [move-grain] lands on
[sim/move-grain]. A name the package does not own the prelude's, or
another package's is left alone, which is the same rule [Load] uses
at import time and the reason both go through [qualify_decl]. *)
let owns =
p.Load.owns
@ List.filter_map Ast.declared_name ds
in
List.map (Load.qualify_decl owns p.Load.alias) ds
in
let loc = let loc =
match incoming with d :: _ -> d.Ast.dloc | [] -> Loc.unknown match incoming with d :: _ -> d.Ast.dloc | [] -> Loc.unknown
in in

View File

@ -127,6 +127,31 @@ let () =
| exception Loc.Error (_, m) -> | exception Loc.Error (_, m) ->
fail "reloading a file with imports failed: %s" 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" if !failures = 0 then print_endline "session: all tests passed"
else begin else begin
Printf.printf "\n%d failure(s)\n" !failures; Printf.printf "\n%d failure(s)\n" !failures;